From 0bb657e11cfa6b9f16cd28340291b121fa1579bc Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 23 Dec 2022 16:06:49 +1100 Subject: [PATCH] refactor(notifier): utilize smtp lib (#4403) This drops a whole heap of code we were maintaining in favor of a SMTP library. Closes #2678 --- .../guides/notification-templates.md | 27 +- go.mod | 4 +- go.sum | 3 +- internal/commands/context.go | 2 +- .../handlers/handler_reset_password_step2.go | 28 +- internal/middlewares/identity_verification.go | 26 +- .../middlewares/identity_verification_test.go | 4 +- internal/mocks/notifier.go | 11 +- internal/notification/const.go | 39 +- internal/notification/file_notifier.go | 49 ++- internal/notification/notifier.go | 4 +- internal/notification/smtp_login_auth.go | 45 --- internal/notification/smtp_login_auth_test.go | 76 ---- internal/notification/smtp_notifier.go | 360 ++++-------------- internal/notification/smtp_notifier_test.go | 50 --- internal/notification/smtp_util.go | 96 ----- internal/notification/smtp_util_test.go | 97 ----- internal/suites/action_mail.go | 3 +- internal/templates/const.go | 12 +- internal/templates/embed_test.go | 16 - internal/templates/html_plaintext_template.go | 23 -- internal/templates/provider.go | 62 +-- .../templates/src/notification/Envelope.tmpl | 5 - internal/templates/types.go | 46 +-- internal/templates/util.go | 55 ++- 25 files changed, 237 insertions(+), 906 deletions(-) delete mode 100644 internal/notification/smtp_login_auth.go delete mode 100644 internal/notification/smtp_login_auth_test.go delete mode 100644 internal/notification/smtp_notifier_test.go delete mode 100644 internal/notification/smtp_util.go delete mode 100644 internal/notification/smtp_util_test.go delete mode 100644 internal/templates/embed_test.go delete mode 100644 internal/templates/html_plaintext_template.go delete mode 100644 internal/templates/src/notification/Envelope.tmpl diff --git a/docs/content/en/reference/guides/notification-templates.md b/docs/content/en/reference/guides/notification-templates.md index 2962a39fe..ec1632e01 100644 --- a/docs/content/en/reference/guides/notification-templates.md +++ b/docs/content/en/reference/guides/notification-templates.md @@ -23,8 +23,7 @@ This guide effectively documents the usage of the 1. The templates are not covered by our stability guarantees. While we aim to avoid changes to the templates which would cause users to have to manually change them changes may be necessary in order to facilitate bug fixes or generally improve the templates. - 1. This is especially important for the [Envelope Template](#envelope-template). - 2. It is your responsibility to ensure your templates are up to date. We make no efforts in facilitating this. + 1. It is your responsibility to ensure your templates are up to date. We make no efforts in facilitating this. 2. We may not be able to offer any direct support in debugging these templates. We only offer support and fixes to the official templates. 3. All templates __*MUST*__ be encoded in UTF-8 with CRLF line endings. The line endings __*MUST NOT*__ be a simple LF. @@ -69,30 +68,6 @@ This is a basic example: Some Additional examples for specific purposes can be found in the [examples directory on GitHub](https://github.com/authelia/authelia/tree/master/examples/templates/notifications). -## Envelope Template - -*__Important Note:__ This template must end with a CRLF newline. Failure to include this newline will result in -malformed emails.* - -There is also a special envelope template. This is the email envelope which contains the content of the other templates -when sent via the SMTP notifier. It's *__strongly recommended__* that you do not modify this template unless you know -what you're doing. If you really want to modify it the name of the file must be `Envelope.tmpl`. - -This template contains the following placeholders which are automatically injected into the template: - -| Placeholder | Description | -|:-----------------------:|:---------------------------------------------------------------------------:| -| `{{ .ProcessID }}` | The Authelia Process ID. | -| `{{ .UUID }}` | A string representation of a UUID v4 generated specifically for this email. | -| `{{ .Host }}` | The configured [host]. | -| `{{ .ServerName }}` | The configured TLS [server_name]. | -| `{{ .SenderDomain }}` | The domain portion of the configured [sender]. | -| `{{ .Identifier }}` | The configured [identifier]. | -| `{{ .From }}` | The string representation of the configured [sender]. | -| `{{ .To }}` | The string representation of the recipients email address. | -| `{{ .Subject }}` | The email subject. | -| `{{ .Date }}` | The time.Time of the email envelope being rendered. | - ## Original Templates The original template content can be found on diff --git a/go.mod b/go.mod index eefa936bc..8e93a3b1a 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/deckarep/golang-set v1.8.0 - github.com/deckarep/golang-set/v2 v2.1.0 github.com/duosecurity/duo_api_golang v0.0.0-20221117185402-091daa09e19d github.com/fasthttp/router v1.4.14 github.com/fasthttp/session/v2 v2.4.13 @@ -40,6 +39,8 @@ require ( github.com/stretchr/testify v1.8.1 github.com/trustelem/zxcvbn v1.0.1 github.com/valyala/fasthttp v1.43.0 + github.com/wneessen/go-mail v0.3.5 + golang.org/x/net v0.1.0 golang.org/x/sync v0.1.0 golang.org/x/term v0.3.0 golang.org/x/text v0.5.0 @@ -109,7 +110,6 @@ require ( github.com/ysmood/leakless v0.8.0 // indirect golang.org/x/crypto v0.1.0 // indirect golang.org/x/mod v0.6.0 // indirect - golang.org/x/net v0.1.0 // indirect golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect golang.org/x/sys v0.3.0 // indirect golang.org/x/tools v0.2.0 // indirect diff --git a/go.sum b/go.sum index 6069c28e5..3dbc8e318 100644 --- a/go.sum +++ b/go.sum @@ -115,7 +115,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= -github.com/deckarep/golang-set/v2 v2.1.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgraph-io/ristretto v0.0.1/go.mod h1:T40EBc7CJke8TkpiYfGGKAeFjSaxuFXhuXRyumBd6RE= github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= @@ -610,6 +609,8 @@ github.com/valyala/fasthttp v1.42.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seB github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g= github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/wneessen/go-mail v0.3.5 h1:5fl4O1SnBpA072WFD+q1KBX6L3ltiIsKQDYjs7sY7GM= +github.com/wneessen/go-mail v0.3.5/go.mod h1:m25lkU2GYQnlVr6tdwK533/UXxo57V0kLOjaFYmub0E= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= diff --git a/internal/commands/context.go b/internal/commands/context.go index ea3026759..737ab9ce0 100644 --- a/internal/commands/context.go +++ b/internal/commands/context.go @@ -166,7 +166,7 @@ func (ctx *CmdCtx) LoadProviders() (warns, errs []error) { switch { case ctx.config.Notifier.SMTP != nil: - providers.Notifier = notification.NewSMTPNotifier(ctx.config.Notifier.SMTP, ctx.trusted, providers.Templates) + providers.Notifier = notification.NewSMTPNotifier(ctx.config.Notifier.SMTP, ctx.trusted) case ctx.config.Notifier.FileSystem != nil: providers.Notifier = notification.NewFileNotifier(*ctx.config.Notifier.FileSystem) } diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index 213a7305e..e7e2a6b5d 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -1,7 +1,6 @@ package handlers import ( - "bytes" "fmt" "github.com/authelia/authelia/v4/internal/middlewares" @@ -74,41 +73,18 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { return } - disableHTML := false - if ctx.Configuration.Notifier.SMTP != nil { - disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails - } - - values := templates.EmailPasswordResetValues{ + data := templates.EmailPasswordResetValues{ Title: "Password changed successfully", DisplayName: userInfo.DisplayName, RemoteIP: ctx.RemoteIP().String(), } - bufHTML, bufText := &bytes.Buffer{}, &bytes.Buffer{} - - if !disableHTML { - if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufHTML, values, templates.HTMLFormat); err != nil { - ctx.Logger.Error(err) - ctx.ReplyOK() - - return - } - } - - if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufText, values, templates.PlainTextFormat); err != nil { - ctx.Logger.Error(err) - ctx.ReplyOK() - - return - } - addresses := userInfo.Addresses() ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", username, addresses[0]) - if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.Bytes(), bufHTML.Bytes()); err != nil { + if err = ctx.Providers.Notifier.Send(ctx, addresses[0], "Password changed successfully", ctx.Providers.Templates.GetPasswordResetEmailTemplate(), data); err != nil { ctx.Logger.Error(err) ctx.ReplyOK() diff --git a/internal/middlewares/identity_verification.go b/internal/middlewares/identity_verification.go index e41a0db5c..aa781cf67 100644 --- a/internal/middlewares/identity_verification.go +++ b/internal/middlewares/identity_verification.go @@ -1,7 +1,6 @@ package middlewares import ( - "bytes" "encoding/json" "fmt" "net/mail" @@ -63,11 +62,6 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim return } - disableHTML := false - if ctx.Configuration.Notifier.SMTP != nil { - disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails - } - linkURL := ctx.RootURL() query := linkURL.Query() @@ -77,7 +71,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim linkURL.Path = path.Join(linkURL.Path, args.TargetEndpoint) linkURL.RawQuery = query.Encode() - values := templates.EmailIdentityVerificationValues{ + data := templates.EmailIdentityVerificationValues{ Title: args.MailTitle, LinkURL: linkURL.String(), LinkText: args.MailButtonContent, @@ -85,24 +79,12 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim RemoteIP: ctx.RemoteIP().String(), } - bufHTML, bufText := &bytes.Buffer{}, &bytes.Buffer{} - - if !disableHTML { - if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufHTML, values, templates.HTMLFormat); err != nil { - ctx.Error(err, messageOperationFailed) - return - } - } - - if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufText, values, templates.PlainTextFormat); err != nil { - ctx.Error(err, messageOperationFailed) - return - } - ctx.Logger.Debugf("Sending an email to user %s (%s) to confirm identity for registering a device.", identity.Username, identity.Email) - if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.Bytes(), bufHTML.Bytes()); err != nil { + recipient := mail.Address{Name: identity.DisplayName, Address: identity.Email} + + if err = ctx.Providers.Notifier.Send(ctx, recipient, args.MailTitle, ctx.Providers.Templates.GetIdentityVerificationEmailTemplate(), data); err != nil { ctx.Error(err, messageOperationFailed) return } diff --git a/internal/middlewares/identity_verification_test.go b/internal/middlewares/identity_verification_test.go index 295648414..bb81141fa 100644 --- a/internal/middlewares/identity_verification_test.go +++ b/internal/middlewares/identity_verification_test.go @@ -81,7 +81,7 @@ func TestShouldFailSendingAnEmail(t *testing.T) { Return(nil) mock.NotifierMock.EXPECT(). - Send(gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()). + Send(gomock.Eq(mock.Ctx), gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()). Return(fmt.Errorf("no notif")) args := newArgs(defaultRetriever) @@ -103,7 +103,7 @@ func TestShouldSucceedIdentityVerificationStartProcess(t *testing.T) { Return(nil) mock.NotifierMock.EXPECT(). - Send(gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()). + Send(gomock.Eq(mock.Ctx), gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()). Return(nil) args := newArgs(defaultRetriever) diff --git a/internal/mocks/notifier.go b/internal/mocks/notifier.go index 208fe2eb6..36ef6f94c 100644 --- a/internal/mocks/notifier.go +++ b/internal/mocks/notifier.go @@ -5,10 +5,13 @@ package mocks import ( + context "context" mail "net/mail" reflect "reflect" gomock "github.com/golang/mock/gomock" + + templates "github.com/authelia/authelia/v4/internal/templates" ) // MockNotifier is a mock of Notifier interface. @@ -35,17 +38,17 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder { } // Send mocks base method. -func (m *MockNotifier) Send(arg0 mail.Address, arg1 string, arg2, arg3 []byte) error { +func (m *MockNotifier) Send(arg0 context.Context, arg1 mail.Address, arg2 string, arg3 *templates.EmailTemplate, arg4 interface{}) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(error) return ret0 } // Send indicates an expected call of Send. -func (mr *MockNotifierMockRecorder) Send(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockNotifierMockRecorder) Send(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockNotifier)(nil).Send), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockNotifier)(nil).Send), arg0, arg1, arg2, arg3, arg4) } // StartupCheck mocks base method. diff --git a/internal/notification/const.go b/internal/notification/const.go index 3534ee359..f244ebef3 100644 --- a/internal/notification/const.go +++ b/internal/notification/const.go @@ -1,49 +1,18 @@ package notification const ( - fileNotifierMode = 0600 + fileNotifierMode = 0600 + fileNotifierHeader = "Date: %s\nRecipient: %s\nSubject: %s\n" ) const ( - smtpAUTHMechanismPlain = "PLAIN" - smtpAUTHMechanismLogin = "LOGIN" - smtpPortSUBMISSIONS = 465 - - smtpCommandDATA = "DATA" - smtpCommandHELLO = "EHLO/HELO" - smtpCommandSTARTTLS = "STARTTLS" - smtpCommandAUTH = "AUTH" - smtpCommandMAIL = "MAIL" - smtpCommandRCPT = "RCPT" - - smtpEncodingQuotedPrintable = "quoted-printable" - smtpEncoding8bit = "8bit" - - smtpContentTypeTextPlain = "text/plain" - smtpContentTypeTextHTML = "text/html" - smtpFmtContentType = `%s; charset="UTF-8"` - smtpFmtContentDispositionInline = "inline" - - smtpExtSTARTTLS = smtpCommandSTARTTLS - smtpExt8BITMIME = "8BITMIME" ) const ( - headerContentType = "Content-Type" - headerContentDisposition = "Content-Disposition" - headerContentTransferEncoding = "Content-Transfer-Encoding" -) - -const ( - fmtSMTPGenericError = "error performing %s with the SMTP server: %w" - fmtSMTPDialError = "error dialing the SMTP server: %w" -) - -const ( - rfc2822NewLine = "\r\n" + posixNewLine = "\n" ) var ( - rfc2822DoubleNewLine = []byte(rfc2822NewLine + rfc2822NewLine) + posixDoubleNewLine = []byte(posixNewLine + posixNewLine) ) diff --git a/internal/notification/file_notifier.go b/internal/notification/file_notifier.go index af2dc91e0..9fcb7cdba 100644 --- a/internal/notification/file_notifier.go +++ b/internal/notification/file_notifier.go @@ -1,6 +1,8 @@ package notification import ( + "bufio" + "context" "fmt" "net/mail" "os" @@ -8,11 +10,13 @@ import ( "time" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/templates" ) // FileNotifier a notifier to send emails to SMTP servers. type FileNotifier struct { - path string + path string + append bool } // NewFileNotifier create an FileNotifier writing the notification into a file. @@ -43,8 +47,45 @@ func (n *FileNotifier) StartupCheck() (err error) { } // Send send a identity verification link to a user. -func (n *FileNotifier) Send(recipient mail.Address, subject string, bodyText, _ []byte) error { - content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, bodyText) +func (n *FileNotifier) Send(_ context.Context, recipient mail.Address, subject string, et *templates.EmailTemplate, data any) (err error) { + var f *os.File - return os.WriteFile(n.path, []byte(content), fileNotifierMode) + var flag int + + switch { + case n.append: + flag = os.O_APPEND | os.O_CREATE | os.O_WRONLY + default: + flag = os.O_TRUNC | os.O_CREATE | os.O_WRONLY + } + + if f, err = os.OpenFile(n.path, flag, fileNotifierMode); err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + + defer f.Close() + + w := bufio.NewWriter(f) + + if _, err = w.WriteString(fmt.Sprintf(fileNotifierHeader, time.Now(), recipient, subject)); err != nil { + return fmt.Errorf("failed to write to the buffer: %w", err) + } + + if err = et.Text.Execute(w, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + if _, err = w.Write(posixDoubleNewLine); err != nil { + return fmt.Errorf("failed to write to the buffer: %w", err) + } + + if err = w.Flush(); err != nil { + return fmt.Errorf("failed to flush buffer: %w", err) + } + + if err = f.Sync(); err != nil { + return fmt.Errorf("failed to sync the file: %w", err) + } + + return nil } diff --git a/internal/notification/notifier.go b/internal/notification/notifier.go index 87c39a9b0..2ac249bef 100644 --- a/internal/notification/notifier.go +++ b/internal/notification/notifier.go @@ -1,14 +1,16 @@ package notification import ( + "context" "net/mail" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/templates" ) // Notifier interface for sending the identity verification link. type Notifier interface { model.StartupCheck - Send(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) + Send(ctx context.Context, recipient mail.Address, subject string, et *templates.EmailTemplate, data any) (err error) } diff --git a/internal/notification/smtp_login_auth.go b/internal/notification/smtp_login_auth.go deleted file mode 100644 index c97fcc579..000000000 --- a/internal/notification/smtp_login_auth.go +++ /dev/null @@ -1,45 +0,0 @@ -package notification - -import ( - "bytes" - "errors" - "fmt" - "net/smtp" -) - -type loginAuth struct { - username string - password string - host string -} - -func newLoginAuth(username, password, host string) smtp.Auth { - return &loginAuth{username, password, host} -} - -func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - if !server.TLS && !(server.Name == "localhost" || server.Name == "127.0.0.1" || server.Name == "::1") { - return "", nil, errors.New("connection over plain-text") - } - - if server.Name != a.host { - return "", nil, errors.New("unexpected hostname from server") - } - - return smtpAUTHMechanismLogin, []byte{}, nil -} - -func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { - if !more { - return nil, nil - } - - switch { - case bytes.Equal(fromServer, []byte("Username:")): - return []byte(a.username), nil - case bytes.Equal(fromServer, []byte("Password:")): - return []byte(a.password), nil - default: - return nil, fmt.Errorf("unexpected server challenge: %s", fromServer) - } -} diff --git a/internal/notification/smtp_login_auth_test.go b/internal/notification/smtp_login_auth_test.go deleted file mode 100644 index e99e9eaed..000000000 --- a/internal/notification/smtp_login_auth_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package notification - -import ( - "fmt" - "net/smtp" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFullLoginAuth(t *testing.T) { - username := "john" - password := "strongpw123" - serverInfo := &smtp.ServerInfo{ - Name: "mail.authelia.com", - TLS: true, - Auth: nil, - } - auth := newLoginAuth(username, password, "mail.authelia.com") - - proto, _, err := auth.Start(serverInfo) - assert.Equal(t, smtpAUTHMechanismLogin, proto) - require.NoError(t, err) - - toServer, err := auth.Next([]byte("Username:"), true) - assert.Equal(t, []byte(username), toServer) - require.NoError(t, err) - - toServer, err = auth.Next([]byte("Password:"), true) - assert.Equal(t, []byte(password), toServer) - require.NoError(t, err) - - toServer, err = auth.Next([]byte(nil), false) - assert.Equal(t, []byte(nil), toServer) - require.NoError(t, err) - - toServer, err = auth.Next([]byte("test"), true) - assert.Equal(t, []byte(nil), toServer) - assert.EqualError(t, err, fmt.Sprintf("unexpected server challenge: %s", []byte("test"))) -} - -func TestShouldHaveUnexpectedHostname(t *testing.T) { - serverInfo := &smtp.ServerInfo{ - Name: "localhost", - TLS: true, - Auth: nil, - } - auth := newLoginAuth("john", "strongpw123", "mail.authelia.com") - _, _, err := auth.Start(serverInfo) - assert.EqualError(t, err, "unexpected hostname from server") -} - -func TestTLSNotNeededForLocalhost(t *testing.T) { - serverInfo := &smtp.ServerInfo{ - Name: "localhost", - TLS: false, - Auth: nil, - } - auth := newLoginAuth("john", "strongpw123", "localhost") - - proto, _, err := auth.Start(serverInfo) - assert.Equal(t, "LOGIN", proto) - require.NoError(t, err) -} - -func TestTLSNeededForNonLocalhost(t *testing.T) { - serverInfo := &smtp.ServerInfo{ - Name: "mail.authelia.com", - TLS: false, - Auth: nil, - } - auth := newLoginAuth("john", "strongpw123", "mail.authelia.com") - _, _, err := auth.Start(serverInfo) - assert.EqualError(t, err, "connection over plain-text") -} diff --git a/internal/notification/smtp_notifier.go b/internal/notification/smtp_notifier.go index 0b4d70786..af4a954d0 100644 --- a/internal/notification/smtp_notifier.go +++ b/internal/notification/smtp_notifier.go @@ -1,21 +1,15 @@ package notification import ( + "context" "crypto/tls" "crypto/x509" - "errors" "fmt" - "io" - "mime/multipart" - "net" "net/mail" - "net/smtp" - "os" "strings" - "time" - "github.com/google/uuid" "github.com/sirupsen/logrus" + gomail "github.com/wneessen/go-mail" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/logging" @@ -24,309 +18,121 @@ import ( ) // NewSMTPNotifier creates a SMTPNotifier using the notifier configuration. -func NewSMTPNotifier(config *schema.SMTPNotifierConfiguration, certPool *x509.CertPool, templateProvider *templates.Provider) *SMTPNotifier { - notifier := &SMTPNotifier{ - config: config, - tlsConfig: utils.NewTLSConfig(config.TLS, certPool), - log: logging.Logger(), - templates: templateProvider, +func NewSMTPNotifier(config *schema.SMTPNotifierConfiguration, certPool *x509.CertPool) *SMTPNotifier { + opts := []gomail.Option{ + gomail.WithPort(config.Port), + gomail.WithTLSConfig(utils.NewTLSConfig(config.TLS, certPool)), + gomail.WithPassword(config.Password), + gomail.WithHELO(config.Identifier), } + switch { + case config.DisableStartTLS: + opts = append(opts, gomail.WithTLSPolicy(gomail.NoTLS)) + case config.DisableRequireTLS: + opts = append(opts, gomail.WithTLSPolicy(gomail.TLSOpportunistic)) + default: + opts = append(opts, gomail.WithTLSPolicy(gomail.TLSMandatory)) + } + + if config.Port == smtpPortSUBMISSIONS { + opts = append(opts, gomail.WithSSL()) + } + + var domain string + at := strings.LastIndex(config.Sender.Address, "@") if at >= 0 { - notifier.domain = config.Sender.Address[at:] + domain = config.Sender.Address[at:] } - return notifier + return &SMTPNotifier{ + config: config, + domain: domain, + tls: utils.NewTLSConfig(config.TLS, certPool), + log: logging.Logger(), + opts: opts, + } } // SMTPNotifier a notifier to send emails to SMTP servers. type SMTPNotifier struct { - config *schema.SMTPNotifierConfiguration - domain string - tlsConfig *tls.Config - log *logrus.Logger - templates *templates.Provider - - client *smtp.Client + config *schema.SMTPNotifierConfiguration + domain string + tls *tls.Config + log *logrus.Logger + opts []gomail.Option } -// Send is used to email a recipient. -func (n *SMTPNotifier) Send(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) { - if err = n.dial(); err != nil { - return fmt.Errorf(fmtSMTPDialError, err) - } - - // Always execute QUIT at the end once we're connected. - defer n.cleanup() - - if err = n.preamble(recipient); err != nil { - return err - } - - // Compose and send the email body to the server. - if err = n.compose(recipient, subject, bodyText, bodyHTML); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandDATA, err) - } - - n.log.Debug("Notifier SMTP client successfully sent email") - - return nil -} - -// StartupCheck implements the startup check provider interface. func (n *SMTPNotifier) StartupCheck() (err error) { - if err = n.dial(); err != nil { - return fmt.Errorf(fmtSMTPDialError, err) + var client *gomail.Client + + if client, err = gomail.NewClient(n.config.Host, n.opts...); err != nil { + return fmt.Errorf("failed to establish client: %w", err) } - // Always execute QUIT at the end once we're connected. - defer n.cleanup() + ctx := context.Background() - if err = n.preamble(n.config.StartupCheckAddress); err != nil { - return err + if err = client.DialWithContext(ctx); err != nil { + return fmt.Errorf("failed to dial connection: %w", err) } - return n.client.Reset() -} - -// preamble performs generic preamble requirements for sending messages via SMTP. -func (n *SMTPNotifier) preamble(recipient mail.Address) (err error) { - if err = n.client.Hello(n.config.Identifier); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandHELLO, err) - } - - if err = n.startTLS(); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandSTARTTLS, err) - } - - if err = n.auth(); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandAUTH, err) - } - - if err = n.client.Mail(n.config.Sender.Address); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandMAIL, err) - } - - if err = n.client.Rcpt(recipient.Address); err != nil { - return fmt.Errorf(fmtSMTPGenericError, smtpCommandRCPT, err) + if err = client.Close(); err != nil { + return fmt.Errorf("failed to close connection: %w", err) } return nil } -// Dial the SMTP server with the SMTPNotifier config. -func (n *SMTPNotifier) dial() (err error) { - var ( - client *smtp.Client - conn net.Conn - dialer = &net.Dialer{Timeout: n.config.Timeout} +func (n *SMTPNotifier) Send(ctx context.Context, recipient mail.Address, subject string, et *templates.EmailTemplate, data any) (err error) { + msg := gomail.NewMsg( + gomail.WithMIMEVersion(gomail.Mime10), + gomail.WithBoundary(utils.RandomString(30, utils.CharSetAlphaNumeric, true)), ) - n.log.Debugf("Notifier SMTP client attempting connection to %s:%d", n.config.Host, n.config.Port) - - if n.config.Port == smtpPortSUBMISSIONS { - n.log.Debugf("Notifier SMTP client using submissions port 465. Make sure the mail server you are connecting to is configured for submissions and not SMTPS.") - - conn, err = tls.DialWithDialer(dialer, "tcp", fmt.Sprintf("%s:%d", n.config.Host, n.config.Port), n.tlsConfig) - } else { - conn, err = dialer.Dial("tcp", fmt.Sprintf("%s:%d", n.config.Host, n.config.Port)) + if err = msg.From(n.config.Sender.String()); err != nil { + return fmt.Errorf("notifier: smtp: failed to set from address: %w", err) } + if err = msg.AddTo(recipient.String()); err != nil { + return fmt.Errorf("notifier: smtp: failed to set to address: %w", err) + } + + msg.Subject(strings.ReplaceAll(n.config.Subject, "{title}", subject)) + switch { - case err == nil: - break - case errors.Is(err, io.EOF): - return fmt.Errorf("received %w error: this error often occurs due to network errors such as a firewall, network policies, or closed ports which may be due to smtp service not running or an incorrect port specified in configuration", err) + case n.config.DisableHTMLEmails: + if err = msg.SetBodyTextTemplate(et.Text, data); err != nil { + return fmt.Errorf("notifier: smtp: failed to set body: text template errored: %w", err) + } default: - return err - } - - if client, err = smtp.NewClient(conn, n.config.Host); err != nil { - return err - } - - n.client = client - - n.log.Debug("Notifier SMTP client connected successfully") - - return nil -} - -// Do startTLS if available (some servers only provide the auth extension after, and encryption is preferred). -func (n *SMTPNotifier) startTLS() error { - // Skips STARTTLS if is disabled in configuration. - if n.config.DisableStartTLS { - n.log.Warn("Notifier SMTP connection has opportunistic STARTTLS explicitly disabled which means all emails will be sent insecurely over plain text and this setting is only necessary for non-compliant SMTP servers which advertise they support STARTTLS when they actually don't support STARTTLS") - return nil - } - - // Only start if not already encrypted. - if _, ok := n.client.TLSConnectionState(); ok { - n.log.Debugf("Notifier SMTP connection is already encrypted, skipping STARTTLS") - return nil - } - - switch ok, _ := n.client.Extension(smtpExtSTARTTLS); ok { - case true: - n.log.Debugf("Notifier SMTP server supports STARTTLS (disableVerifyCert: %t, ServerName: %s), attempting", n.tlsConfig.InsecureSkipVerify, n.tlsConfig.ServerName) - - if err := n.client.StartTLS(n.tlsConfig); err != nil { - return err + if err = msg.AddAlternativeHTMLTemplate(et.HTML, data); err != nil { + return fmt.Errorf("notifier: smtp: failed to set body: html template errored: %w", err) } - n.log.Debug("Notifier SMTP STARTTLS completed without error") - default: - switch n.config.DisableRequireTLS { - case true: - n.log.Warn("Notifier SMTP server does not support STARTTLS and SMTP configuration is set to disable the TLS requirement (only useful for unauthenticated emails over plain text)") - default: - return errors.New("server does not support TLS and it is required by default (see documentation if you want to disable this highly recommended requirement)") + if err = msg.AddAlternativeTextTemplate(et.Text, data); err != nil { + return fmt.Errorf("notifier: smtp: failed to set body: text template errored: %w", err) } } + var client *gomail.Client + + if client, err = gomail.NewClient(n.config.Host, n.opts...); err != nil { + return fmt.Errorf("notifier: smtp: failed to establish client: %w", err) + } + + if err = client.DialWithContext(ctx); err != nil { + return fmt.Errorf("notifier: smtp: failed to dial connection: %w", err) + } + + if err = client.Send(msg); err != nil { + return fmt.Errorf("notifier: smtp: failed to send message: %w", err) + } + + if err = client.Close(); err != nil { + return fmt.Errorf("notifier: smtp: failed to close connection: %w", err) + } + return nil } - -// Attempt Authentication. -func (n *SMTPNotifier) auth() (err error) { - // Attempt AUTH if password is specified only. - if n.config.Password != "" { - var ( - ok bool - m string - ) - - if _, ok = n.client.TLSConnectionState(); !ok { - return errors.New("client does not support authentication over plain text and the connection is currently plain text") - } - - // Check the server supports AUTH, and get the mechanisms. - if ok, m = n.client.Extension(smtpCommandAUTH); ok { - var auth smtp.Auth - - n.log.Debugf("Notifier SMTP server supports authentication with the following mechanisms: %s", m) - - mechanisms := strings.Split(m, " ") - - // Adaptively select the AUTH mechanism to use based on what the server advertised. - if utils.IsStringInSlice(smtpAUTHMechanismPlain, mechanisms) { - auth = smtp.PlainAuth("", n.config.Username, n.config.Password, n.config.Host) - - n.log.Debug("Notifier SMTP client attempting AUTH PLAIN with server") - } else if utils.IsStringInSlice(smtpAUTHMechanismLogin, mechanisms) { - auth = newLoginAuth(n.config.Username, n.config.Password, n.config.Host) - - n.log.Debug("Notifier SMTP client attempting AUTH LOGIN with server") - } - - // Throw error since AUTH extension is not supported. - if auth == nil { - return fmt.Errorf("server does not advertise an AUTH mechanism that is supported (PLAIN or LOGIN are supported, but server advertised mechanisms '%s')", m) - } - - // Authenticate. - if err = n.client.Auth(auth); err != nil { - return err - } - - n.log.Debug("Notifier SMTP client authenticated successfully with the server") - - return nil - } - - return errors.New("server does not advertise the AUTH extension but config requires AUTH (password specified), either disable AUTH, or use an SMTP host that supports AUTH PLAIN or AUTH LOGIN") - } - - n.log.Debug("Notifier SMTP config has no password specified so authentication is being skipped") - - return nil -} - -func (n *SMTPNotifier) compose(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error) { - n.log.Debugf("Notifier SMTP client attempting to send email body to %s", recipient.String()) - - if !n.config.DisableRequireTLS { - _, ok := n.client.TLSConnectionState() - if !ok { - return errors.New("client can't send an email over plain text connection") - } - } - - var ( - wc io.WriteCloser - muuid uuid.UUID - ) - - if wc, err = n.client.Data(); err != nil { - n.log.Debugf("Notifier SMTP client error while obtaining WriteCloser: %v", err) - return err - } - - if muuid, err = uuid.NewRandom(); err != nil { - return err - } - - data := templates.EmailEnvelopeValues{ - ProcessID: os.Getpid(), - UUID: muuid.String(), - Host: n.config.Host, - ServerName: n.config.TLS.ServerName, - SenderDomain: n.domain, - Identifier: n.config.Identifier, - From: n.config.Sender.String(), - To: recipient.String(), - Subject: strings.ReplaceAll(n.config.Subject, "{title}", subject), - Date: time.Now(), - } - - if err = n.templates.ExecuteEmailEnvelope(wc, data); err != nil { - n.log.Debugf("Notifier SMTP client error while sending email body over WriteCloser: %v", err) - - return err - } - - mwr := multipart.NewWriter(wc) - - if _, err = wc.Write([]byte(fmt.Sprintf(`Content-Type: multipart/alternative; boundary="%s"`, mwr.Boundary()))); err != nil { - return err - } - - if _, err = wc.Write(rfc2822DoubleNewLine); err != nil { - return err - } - - ext8BITMIME, _ := n.client.Extension(smtpExt8BITMIME) - - if err = multipartWrite(mwr, smtpMIMEHeaders(ext8BITMIME, smtpContentTypeTextPlain, bodyText), bodyText); err != nil { - return fmt.Errorf("failed to write text/plain part: %w", err) - } - - if len(bodyHTML) != 0 { - if err = multipartWrite(mwr, - smtpMIMEHeaders(ext8BITMIME, smtpContentTypeTextHTML, bodyText), bodyHTML); err != nil { - return fmt.Errorf("failed to write text/html part: %w", err) - } - } - - if err = mwr.Close(); err != nil { - return fmt.Errorf("failed to finalize the multipart content: %w", err) - } - - if err = wc.Close(); err != nil { - n.log.Debugf("Notifier SMTP client error while closing the WriteCloser: %v", err) - return err - } - - return nil -} - -// Closes the connection properly. -func (n *SMTPNotifier) cleanup() { - if err := n.client.Quit(); err != nil { - n.log.Warnf("Notifier SMTP client encountered error during cleanup: %v", err) - } - - n.client = nil -} diff --git a/internal/notification/smtp_notifier_test.go b/internal/notification/smtp_notifier_test.go deleted file mode 100644 index 0ecc3f36d..000000000 --- a/internal/notification/smtp_notifier_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package notification - -import ( - "crypto/tls" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/authelia/authelia/v4/internal/configuration/schema" - "github.com/authelia/authelia/v4/internal/templates" -) - -func TestShouldConfigureSMTPNotifierWithTLS11(t *testing.T) { - config := &schema.NotifierConfiguration{ - DisableStartupCheck: true, - SMTP: &schema.SMTPNotifierConfiguration{ - Host: "smtp.example.com", - Port: 25, - TLS: &schema.TLSConfig{ - ServerName: "smtp.example.com", - MinimumVersion: schema.TLSVersion{Value: tls.VersionTLS11}, - }, - }, - } - - notifier := NewSMTPNotifier(config.SMTP, nil, &templates.Provider{}) - - assert.Equal(t, "smtp.example.com", notifier.tlsConfig.ServerName) - assert.Equal(t, uint16(tls.VersionTLS11), notifier.tlsConfig.MinVersion) - assert.False(t, notifier.tlsConfig.InsecureSkipVerify) -} - -func TestShouldConfigureSMTPNotifierWithServerNameOverrideAndDefaultTLS12(t *testing.T) { - config := &schema.NotifierConfiguration{ - DisableStartupCheck: true, - SMTP: &schema.SMTPNotifierConfiguration{ - Host: "smtp.example.com", - Port: 25, - TLS: &schema.TLSConfig{ - ServerName: "smtp.golang.org", - }, - }, - } - - notifier := NewSMTPNotifier(config.SMTP, nil, &templates.Provider{}) - - assert.Equal(t, "smtp.golang.org", notifier.tlsConfig.ServerName) - assert.Equal(t, uint16(tls.VersionTLS12), notifier.tlsConfig.MinVersion) - assert.False(t, notifier.tlsConfig.InsecureSkipVerify) -} diff --git a/internal/notification/smtp_util.go b/internal/notification/smtp_util.go deleted file mode 100644 index 4a1258e12..000000000 --- a/internal/notification/smtp_util.go +++ /dev/null @@ -1,96 +0,0 @@ -package notification - -import ( - "fmt" - "io" - "mime/multipart" - "mime/quotedprintable" - "net/textproto" - - "github.com/authelia/authelia/v4/internal/utils" -) - -func smtpMIMEHeaders(ext8BITMIME bool, contentType string, data []byte) textproto.MIMEHeader { - headers := textproto.MIMEHeader{ - headerContentType: []string{fmt.Sprintf(smtpFmtContentType, contentType)}, - headerContentDisposition: []string{smtpFmtContentDispositionInline}, - } - - characteristics := NewMIMECharacteristics(data) - - if !ext8BITMIME || characteristics.LongLines || characteristics.Characters8BIT { - headers.Set(headerContentTransferEncoding, smtpEncodingQuotedPrintable) - } else { - headers.Set(headerContentTransferEncoding, smtpEncoding8bit) - } - - return headers -} - -func multipartWrite(mwr *multipart.Writer, header textproto.MIMEHeader, data []byte) (err error) { - var ( - wc io.WriteCloser - wr io.Writer - ) - - if wr, err = mwr.CreatePart(header); err != nil { - return err - } - - switch header.Get(headerContentTransferEncoding) { - case smtpEncodingQuotedPrintable: - wc = quotedprintable.NewWriter(wr) - case smtpEncoding8bit, "": - wc = utils.NewWriteCloser(wr) - default: - return fmt.Errorf("unknown encoding: %s", header.Get(headerContentTransferEncoding)) - } - - if _, err = wc.Write(data); err != nil { - _ = wc.Close() - - return err - } - - _ = wc.Close() - - return nil -} - -// NewMIMECharacteristics detects the SMTP MIMECharacteristics for the given data bytes. -func NewMIMECharacteristics(data []byte) MIMECharacteristics { - characteristics := MIMECharacteristics{} - - cl := 0 - - n := len(data) - - for i := 0; i < n; i++ { - cl++ - - if cl > 1000 { - characteristics.LongLines = true - } - - if data[i] == 10 { - cl = 0 - - if i == 0 || data[i-1] != 13 { - characteristics.LineFeeds = true - } - } - - if data[i] >= 128 { - characteristics.Characters8BIT = true - } - } - - return characteristics -} - -// MIMECharacteristics represents specific MIME related characteristics. -type MIMECharacteristics struct { - LongLines bool - LineFeeds bool - Characters8BIT bool -} diff --git a/internal/notification/smtp_util_test.go b/internal/notification/smtp_util_test.go deleted file mode 100644 index 39c2d6617..000000000 --- a/internal/notification/smtp_util_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package notification - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/authelia/authelia/v4/internal/utils" -) - -func TestNewMIMECharacteristics(t *testing.T) { - testCases := []struct { - name string - expected MIMECharacteristics - have []byte - }{ - { - "ShouldDetectMessageCharacteristics7Bit", - MIMECharacteristics{}, - createMIMEBytes(false, true, 5, 150), - }, - { - "ShouldDetectMessageCharacteristicsLongLine", - MIMECharacteristics{LongLines: true}, - createMIMEBytes(false, true, 3, 1200), - }, - { - "ShouldDetectMessageCharacteristicsLF", - MIMECharacteristics{LineFeeds: true}, - createMIMEBytes(false, false, 5, 150), - }, - { - "ShouldDetectMessageCharacteristics8Bit", - MIMECharacteristics{Characters8BIT: true}, - createMIMEBytes(true, true, 3, 150), - }, - { - "ShouldDetectMessageCharacteristicsLongLineAndLF", - MIMECharacteristics{true, true, false}, - createMIMEBytes(false, false, 3, 1200), - }, - { - "ShouldDetectMessageCharacteristicsLongLineAnd8Bit", - MIMECharacteristics{true, false, true}, - createMIMEBytes(true, true, 3, 1200), - }, - { - "ShouldDetectMessageCharacteristics8BitAndLF", - MIMECharacteristics{false, true, true}, - createMIMEBytes(true, false, 3, 150), - }, - { - "ShouldDetectMessageCharacteristicsAll", - MIMECharacteristics{true, true, true}, - createMIMEBytes(true, false, 3, 1200), - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - actual := NewMIMECharacteristics(tc.have) - - assert.Equal(t, tc.expected, actual) - }) - } -} - -func createMIMEBytes(include8bit, crlf bool, lines, length int) []byte { - buf := &bytes.Buffer{} - - for i := 0; i < lines; i++ { - for j := 0; j < length/100; j++ { - switch { - case include8bit: - buf.Write(utils.RandomBytes(50, utils.CharSetAlphaNumeric, false)) - buf.Write([]byte("£")) - buf.Write(utils.RandomBytes(49, utils.CharSetAlphabetic, false)) - default: - buf.Write(utils.RandomBytes(100, utils.CharSetAlphaNumeric, false)) - } - } - - if n := length % 100; n != 0 { - buf.Write(utils.RandomBytes(n, utils.CharSetAlphaNumeric, false)) - } - - switch { - case crlf: - buf.Write([]byte(rfc2822NewLine)) - default: - buf.Write([]byte("\n")) - } - } - - return buf.Bytes() -} diff --git a/internal/suites/action_mail.go b/internal/suites/action_mail.go index e127fdbea..094934deb 100644 --- a/internal/suites/action_mail.go +++ b/internal/suites/action_mail.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type message struct { @@ -27,7 +28,7 @@ func doGetLinkFromLastMail(t *testing.T) string { re := regexp.MustCompile(`.*<\/a>`) matches := re.FindStringSubmatch(string(res)) - assert.Len(t, matches, 2, "Number of match for link in email is not equal to one") + require.Len(t, matches, 2, "Number of match for link in email is not equal to one") return matches[1] } diff --git a/internal/templates/const.go b/internal/templates/const.go index 38f81961e..4990dd5b2 100644 --- a/internal/templates/const.go +++ b/internal/templates/const.go @@ -1,12 +1,14 @@ package templates +const ( + extText = ".txt" + extHTML = ".html" +) + // Template File Names. const ( - TemplateNameEmailEnvelope = "Envelope.tmpl" - TemplateNameEmailIdentityVerificationHTML = "IdentityVerification.html" - TemplateNameEmailIdentityVerificationTXT = "IdentityVerification.txt" - TemplateNameEmailPasswordResetHTML = "PasswordReset.html" - TemplateNameEmailPasswordResetTXT = "PasswordReset.txt" + TemplateNameEmailIdentityVerification = "IdentityVerification" + TemplateNameEmailPasswordReset = "PasswordReset" ) // Template Category Names. diff --git a/internal/templates/embed_test.go b/internal/templates/embed_test.go deleted file mode 100644 index 75080ef6e..000000000 --- a/internal/templates/embed_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package templates - -import ( - "path" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestShouldNotHaveDeprecatedPlaceholders(t *testing.T) { - data, err := embedFS.ReadFile(path.Join("src", TemplateCategoryNotifications, TemplateNameEmailEnvelope)) - require.NoError(t, err) - - assert.False(t, tmplEnvelopeHasDeprecatedPlaceholders(data)) -} diff --git a/internal/templates/html_plaintext_template.go b/internal/templates/html_plaintext_template.go deleted file mode 100644 index d1b1fdc59..000000000 --- a/internal/templates/html_plaintext_template.go +++ /dev/null @@ -1,23 +0,0 @@ -package templates - -import ( - "text/template" -) - -// HTMLPlainTextTemplate is the template type which contains both the html and txt versions of a template. -type HTMLPlainTextTemplate struct { - html *template.Template - txt *template.Template -} - -// Get returns the appropriate template given the format. -func (f HTMLPlainTextTemplate) Get(format Format) (t *template.Template) { - switch format { - case HTMLFormat: - return f.html - case PlainTextFormat: - return f.txt - default: - return f.html - } -} diff --git a/internal/templates/provider.go b/internal/templates/provider.go index 500be22e9..3f94ad8c6 100644 --- a/internal/templates/provider.go +++ b/internal/templates/provider.go @@ -2,7 +2,6 @@ package templates import ( "fmt" - "io" ) // New creates a new templates' provider. @@ -24,66 +23,39 @@ type Provider struct { templates Templates } -// ExecuteEmailEnvelope writes the envelope template to the given io.Writer. -func (p *Provider) ExecuteEmailEnvelope(wr io.Writer, data EmailEnvelopeValues) (err error) { - return p.templates.notification.envelope.Execute(wr, data) +// GetPasswordResetEmailTemplate returns the EmailTemplate for Password Reset notifications. +func (p *Provider) GetPasswordResetEmailTemplate() (t *EmailTemplate) { + return p.templates.notification.passwordReset } -// ExecuteEmailPasswordResetTemplate writes the password reset template to the given io.Writer. -func (p *Provider) ExecuteEmailPasswordResetTemplate(wr io.Writer, data EmailPasswordResetValues, format Format) (err error) { - return p.templates.notification.passwordReset.Get(format).Execute(wr, data) -} - -// ExecuteEmailIdentityVerificationTemplate writes the identity verification template to the given io.Writer. -func (p *Provider) ExecuteEmailIdentityVerificationTemplate(wr io.Writer, data EmailIdentityVerificationValues, format Format) (err error) { - return p.templates.notification.identityVerification.Get(format).Execute(wr, data) +// GetIdentityVerificationEmailTemplate returns the EmailTemplate for Identity Verification notifications. +func (p *Provider) GetIdentityVerificationEmailTemplate() (t *EmailTemplate) { + return p.templates.notification.identityVerification } func (p *Provider) load() (err error) { var errs []error - if tPath, embed, data, err := readTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { - errs = append(errs, err) - } else { - if !embed && tmplEnvelopeHasDeprecatedPlaceholders(data) { - errs = append(errs, fmt.Errorf("the evelope template override appears to contain removed placeholders")) - } else if p.templates.notification.envelope, err = parseTemplate(TemplateNameEmailEnvelope, tPath, embed, data); err != nil { - errs = append(errs, err) - } - } - - if p.templates.notification.envelope, err = loadTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { + if p.templates.notification.identityVerification, err = loadEmailTemplate(TemplateNameEmailIdentityVerification, p.config.EmailTemplatesPath); err != nil { errs = append(errs, err) } - if p.templates.notification.identityVerification.txt, err = loadTemplate(TemplateNameEmailIdentityVerificationTXT, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { + if p.templates.notification.passwordReset, err = loadEmailTemplate(TemplateNameEmailPasswordReset, p.config.EmailTemplatesPath); err != nil { errs = append(errs, err) } - if p.templates.notification.identityVerification.html, err = loadTemplate(TemplateNameEmailIdentityVerificationHTML, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { - errs = append(errs, err) - } + if len(errs) != 0 { + for i, e := range errs { + if i == 0 { + err = e + continue + } - if p.templates.notification.passwordReset.txt, err = loadTemplate(TemplateNameEmailPasswordResetTXT, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { - errs = append(errs, err) - } - - if p.templates.notification.passwordReset.html, err = loadTemplate(TemplateNameEmailPasswordResetHTML, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { - errs = append(errs, err) - } - - if len(errs) == 0 { - return nil - } - - for i, e := range errs { - if i == 0 { - err = e - continue + err = fmt.Errorf("%v, %w", err, e) } - err = fmt.Errorf("%v, %w", err, e) + return fmt.Errorf("one or more errors occurred loading templates: %w", err) } - return fmt.Errorf("one or more errors occurred loading templates: %w", err) + return nil } diff --git a/internal/templates/src/notification/Envelope.tmpl b/internal/templates/src/notification/Envelope.tmpl deleted file mode 100644 index 813cbef71..000000000 --- a/internal/templates/src/notification/Envelope.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -From: {{ .From }} -To: {{ .To }} -Subject: {{ .Subject }} -Date: {{ .Date.Format "Mon, 2 Jan 2006 15:04:05 -0700" }} -MIME-Version: 1.0 diff --git a/internal/templates/types.go b/internal/templates/types.go index daa7fc0c8..105bb60d4 100644 --- a/internal/templates/types.go +++ b/internal/templates/types.go @@ -1,8 +1,9 @@ package templates import ( - "text/template" - "time" + th "html/template" + "io" + tt "text/template" ) // Templates is the struct which holds all the *template.Template values. @@ -12,26 +13,29 @@ type Templates struct { // NotificationTemplates are the templates for the notification system. type NotificationTemplates struct { - envelope *template.Template - passwordReset HTMLPlainTextTemplate - identityVerification HTMLPlainTextTemplate + passwordReset *EmailTemplate + identityVerification *EmailTemplate } -// Format of a template. -type Format int - -// Formats. -const ( - DefaultFormat Format = iota - HTMLFormat - PlainTextFormat -) +// Template covers shared implementations between the text and html template.Template. +type Template interface { + Execute(wr io.Writer, data any) error + ExecuteTemplate(wr io.Writer, name string, data any) error + Name() string + DefinedTemplates() string +} // Config for the Provider. type Config struct { EmailTemplatesPath string } +// EmailTemplate is the template type which contains both the html and txt versions of a template. +type EmailTemplate struct { + HTML *th.Template + Text *tt.Template +} + // EmailPasswordResetValues are the values used for password reset templates. type EmailPasswordResetValues struct { UUID string @@ -49,17 +53,3 @@ type EmailIdentityVerificationValues struct { LinkURL string LinkText string } - -// EmailEnvelopeValues are the values used for the email envelopes. -type EmailEnvelopeValues struct { - ProcessID int - UUID string - Host string - ServerName string - SenderDomain string - Identifier string - From string - To string - Subject string - Date time.Time -} diff --git a/internal/templates/util.go b/internal/templates/util.go index f747b478a..e1c26650c 100644 --- a/internal/templates/util.go +++ b/internal/templates/util.go @@ -1,20 +1,14 @@ package templates import ( - "bytes" "fmt" + th "html/template" "os" "path" "path/filepath" - "text/template" + tt "text/template" ) -func tmplEnvelopeHasDeprecatedPlaceholders(data []byte) bool { - return bytes.Contains(data, []byte("{{ .Boundary }}")) || - bytes.Contains(data, []byte("{{ .Body.PlainText }}")) || - bytes.Contains(data, []byte("{{ .Body.HTML }}")) -} - func templateExists(path string) (exists bool) { info, err := os.Stat(path) if err != nil { @@ -28,9 +22,9 @@ func templateExists(path string) (exists bool) { return true } -func readTemplate(name, category, overridePath string) (tPath string, embed bool, data []byte, err error) { +func readTemplate(name, ext, category, overridePath string) (tPath string, embed bool, data []byte, err error) { if overridePath != "" { - tPath = filepath.Join(overridePath, name) + tPath = filepath.Join(overridePath, name+ext) if templateExists(tPath) { if data, err = os.ReadFile(tPath); err != nil { @@ -41,7 +35,7 @@ func readTemplate(name, category, overridePath string) (tPath string, embed bool } } - tPath = path.Join("src", category, name) + tPath = path.Join("src", category, name+ext) if data, err = embedFS.ReadFile(tPath); err != nil { return tPath, true, nil, fmt.Errorf("failed to read embedded template '%s': %w", tPath, err) @@ -50,8 +44,8 @@ func readTemplate(name, category, overridePath string) (tPath string, embed bool return tPath, true, data, nil } -func parseTemplate(name, tPath string, embed bool, data []byte) (t *template.Template, err error) { - if t, err = template.New(name).Parse(string(data)); err != nil { +func parseTextTemplate(name, tPath string, embed bool, data []byte) (t *tt.Template, err error) { + if t, err = tt.New(name + extText).Parse(string(data)); err != nil { if embed { return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) } @@ -62,17 +56,42 @@ func parseTemplate(name, tPath string, embed bool, data []byte) (t *template.Tem return t, nil } -//nolint:unparam -func loadTemplate(name, category, overridePath string) (t *template.Template, err error) { +func parseHTMLTemplate(name, tPath string, embed bool, data []byte) (t *th.Template, err error) { + if t, err = th.New(name + extHTML).Parse(string(data)); err != nil { + if embed { + return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) + } + + return nil, fmt.Errorf("failed to parse template override at path '%s': %w", tPath, err) + } + + return t, nil +} + +func loadEmailTemplate(name, overridePath string) (t *EmailTemplate, err error) { var ( embed bool - tPath string + tpath string data []byte ) - if tPath, embed, data, err = readTemplate(name, category, overridePath); err != nil { + t = &EmailTemplate{} + + if tpath, embed, data, err = readTemplate(name, extText, TemplateCategoryNotifications, overridePath); err != nil { return nil, err } - return parseTemplate(name, tPath, embed, data) + if t.Text, err = parseTextTemplate(name, tpath, embed, data); err != nil { + return nil, err + } + + if tpath, embed, data, err = readTemplate(name, extHTML, TemplateCategoryNotifications, overridePath); err != nil { + return nil, err + } + + if t.HTML, err = parseHTMLTemplate(name, tpath, embed, data); err != nil { + return nil, err + } + + return t, nil }