refactor(notifier): utilize smtp lib (#4403)

This drops a whole heap of code we were maintaining in favor of a SMTP library.

Closes #2678
pull/4634/head
James Elliott 2022-12-23 16:06:49 +11:00 committed by GitHub
parent 38ca5f06d4
commit 0bb657e11c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 237 additions and 906 deletions

View File

@ -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 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 would cause users to have to manually change them changes may be necessary in order to facilitate bug fixes or
generally improve the templates. generally improve the templates.
1. This is especially important for the [Envelope Template](#envelope-template). 1. It is your responsibility to ensure your templates are up to date. We make no efforts in facilitating this.
2. 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 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. 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. 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 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). [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 ## Original Templates
The original template content can be found on The original template content can be found on

4
go.mod
View File

@ -6,7 +6,6 @@ require (
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/deckarep/golang-set v1.8.0 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/duosecurity/duo_api_golang v0.0.0-20221117185402-091daa09e19d
github.com/fasthttp/router v1.4.14 github.com/fasthttp/router v1.4.14
github.com/fasthttp/session/v2 v2.4.13 github.com/fasthttp/session/v2 v2.4.13
@ -40,6 +39,8 @@ require (
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.1
github.com/trustelem/zxcvbn v1.0.1 github.com/trustelem/zxcvbn v1.0.1
github.com/valyala/fasthttp v1.43.0 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/sync v0.1.0
golang.org/x/term v0.3.0 golang.org/x/term v0.3.0
golang.org/x/text v0.5.0 golang.org/x/text v0.5.0
@ -109,7 +110,6 @@ require (
github.com/ysmood/leakless v0.8.0 // indirect github.com/ysmood/leakless v0.8.0 // indirect
golang.org/x/crypto v0.1.0 // indirect golang.org/x/crypto v0.1.0 // indirect
golang.org/x/mod v0.6.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/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sys v0.3.0 // indirect golang.org/x/sys v0.3.0 // indirect
golang.org/x/tools v0.2.0 // indirect golang.org/x/tools v0.2.0 // indirect

3
go.sum
View File

@ -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/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 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= 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.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.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= 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 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= 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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=

View File

@ -166,7 +166,7 @@ func (ctx *CmdCtx) LoadProviders() (warns, errs []error) {
switch { switch {
case ctx.config.Notifier.SMTP != nil: 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: case ctx.config.Notifier.FileSystem != nil:
providers.Notifier = notification.NewFileNotifier(*ctx.config.Notifier.FileSystem) providers.Notifier = notification.NewFileNotifier(*ctx.config.Notifier.FileSystem)
} }

View File

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"bytes"
"fmt" "fmt"
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
@ -74,41 +73,18 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
disableHTML := false data := templates.EmailPasswordResetValues{
if ctx.Configuration.Notifier.SMTP != nil {
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
}
values := templates.EmailPasswordResetValues{
Title: "Password changed successfully", Title: "Password changed successfully",
DisplayName: userInfo.DisplayName, DisplayName: userInfo.DisplayName,
RemoteIP: ctx.RemoteIP().String(), 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() addresses := userInfo.Addresses()
ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.", ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.",
username, addresses[0]) 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.Logger.Error(err)
ctx.ReplyOK() ctx.ReplyOK()

View File

@ -1,7 +1,6 @@
package middlewares package middlewares
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/mail" "net/mail"
@ -63,11 +62,6 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
return return
} }
disableHTML := false
if ctx.Configuration.Notifier.SMTP != nil {
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
}
linkURL := ctx.RootURL() linkURL := ctx.RootURL()
query := linkURL.Query() query := linkURL.Query()
@ -77,7 +71,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
linkURL.Path = path.Join(linkURL.Path, args.TargetEndpoint) linkURL.Path = path.Join(linkURL.Path, args.TargetEndpoint)
linkURL.RawQuery = query.Encode() linkURL.RawQuery = query.Encode()
values := templates.EmailIdentityVerificationValues{ data := templates.EmailIdentityVerificationValues{
Title: args.MailTitle, Title: args.MailTitle,
LinkURL: linkURL.String(), LinkURL: linkURL.String(),
LinkText: args.MailButtonContent, LinkText: args.MailButtonContent,
@ -85,24 +79,12 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
RemoteIP: ctx.RemoteIP().String(), 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.", ctx.Logger.Debugf("Sending an email to user %s (%s) to confirm identity for registering a device.",
identity.Username, identity.Email) 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) ctx.Error(err, messageOperationFailed)
return return
} }

View File

@ -81,7 +81,7 @@ func TestShouldFailSendingAnEmail(t *testing.T) {
Return(nil) Return(nil)
mock.NotifierMock.EXPECT(). 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")) Return(fmt.Errorf("no notif"))
args := newArgs(defaultRetriever) args := newArgs(defaultRetriever)
@ -103,7 +103,7 @@ func TestShouldSucceedIdentityVerificationStartProcess(t *testing.T) {
Return(nil) Return(nil)
mock.NotifierMock.EXPECT(). 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) Return(nil)
args := newArgs(defaultRetriever) args := newArgs(defaultRetriever)

View File

@ -5,10 +5,13 @@
package mocks package mocks
import ( import (
context "context"
mail "net/mail" mail "net/mail"
reflect "reflect" reflect "reflect"
gomock "github.com/golang/mock/gomock" gomock "github.com/golang/mock/gomock"
templates "github.com/authelia/authelia/v4/internal/templates"
) )
// MockNotifier is a mock of Notifier interface. // MockNotifier is a mock of Notifier interface.
@ -35,17 +38,17 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder {
} }
// Send mocks base method. // 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() 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) ret0, _ := ret[0].(error)
return ret0 return ret0
} }
// Send indicates an expected call of Send. // 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() 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. // StartupCheck mocks base method.

View File

@ -1,49 +1,18 @@
package notification package notification
const ( const (
fileNotifierMode = 0600 fileNotifierMode = 0600
fileNotifierHeader = "Date: %s\nRecipient: %s\nSubject: %s\n"
) )
const ( const (
smtpAUTHMechanismPlain = "PLAIN"
smtpAUTHMechanismLogin = "LOGIN"
smtpPortSUBMISSIONS = 465 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 ( const (
headerContentType = "Content-Type" posixNewLine = "\n"
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"
) )
var ( var (
rfc2822DoubleNewLine = []byte(rfc2822NewLine + rfc2822NewLine) posixDoubleNewLine = []byte(posixNewLine + posixNewLine)
) )

View File

@ -1,6 +1,8 @@
package notification package notification
import ( import (
"bufio"
"context"
"fmt" "fmt"
"net/mail" "net/mail"
"os" "os"
@ -8,11 +10,13 @@ import (
"time" "time"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/templates"
) )
// FileNotifier a notifier to send emails to SMTP servers. // FileNotifier a notifier to send emails to SMTP servers.
type FileNotifier struct { type FileNotifier struct {
path string path string
append bool
} }
// NewFileNotifier create an FileNotifier writing the notification into a file. // 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. // Send send a identity verification link to a user.
func (n *FileNotifier) Send(recipient mail.Address, subject string, bodyText, _ []byte) error { func (n *FileNotifier) Send(_ context.Context, recipient mail.Address, subject string, et *templates.EmailTemplate, data any) (err error) {
content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, bodyText) 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
} }

View File

@ -1,14 +1,16 @@
package notification package notification
import ( import (
"context"
"net/mail" "net/mail"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/templates"
) )
// Notifier interface for sending the identity verification link. // Notifier interface for sending the identity verification link.
type Notifier interface { type Notifier interface {
model.StartupCheck 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)
} }

View File

@ -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)
}
}

View File

@ -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")
}

View File

@ -1,21 +1,15 @@
package notification package notification
import ( import (
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"fmt" "fmt"
"io"
"mime/multipart"
"net"
"net/mail" "net/mail"
"net/smtp"
"os"
"strings" "strings"
"time"
"github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
gomail "github.com/wneessen/go-mail"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging" "github.com/authelia/authelia/v4/internal/logging"
@ -24,309 +18,121 @@ import (
) )
// NewSMTPNotifier creates a SMTPNotifier using the notifier configuration. // NewSMTPNotifier creates a SMTPNotifier using the notifier configuration.
func NewSMTPNotifier(config *schema.SMTPNotifierConfiguration, certPool *x509.CertPool, templateProvider *templates.Provider) *SMTPNotifier { func NewSMTPNotifier(config *schema.SMTPNotifierConfiguration, certPool *x509.CertPool) *SMTPNotifier {
notifier := &SMTPNotifier{ opts := []gomail.Option{
config: config, gomail.WithPort(config.Port),
tlsConfig: utils.NewTLSConfig(config.TLS, certPool), gomail.WithTLSConfig(utils.NewTLSConfig(config.TLS, certPool)),
log: logging.Logger(), gomail.WithPassword(config.Password),
templates: templateProvider, 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, "@") at := strings.LastIndex(config.Sender.Address, "@")
if at >= 0 { 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. // SMTPNotifier a notifier to send emails to SMTP servers.
type SMTPNotifier struct { type SMTPNotifier struct {
config *schema.SMTPNotifierConfiguration config *schema.SMTPNotifierConfiguration
domain string domain string
tlsConfig *tls.Config tls *tls.Config
log *logrus.Logger log *logrus.Logger
templates *templates.Provider opts []gomail.Option
client *smtp.Client
} }
// 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) { func (n *SMTPNotifier) StartupCheck() (err error) {
if err = n.dial(); err != nil { var client *gomail.Client
return fmt.Errorf(fmtSMTPDialError, err)
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. ctx := context.Background()
defer n.cleanup()
if err = n.preamble(n.config.StartupCheckAddress); err != nil { if err = client.DialWithContext(ctx); err != nil {
return err return fmt.Errorf("failed to dial connection: %w", err)
} }
return n.client.Reset() if err = client.Close(); err != nil {
} return fmt.Errorf("failed to close connection: %w", err)
// 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)
} }
return nil return nil
} }
// Dial the SMTP server with the SMTPNotifier config. func (n *SMTPNotifier) Send(ctx context.Context, recipient mail.Address, subject string, et *templates.EmailTemplate, data any) (err error) {
func (n *SMTPNotifier) dial() (err error) { msg := gomail.NewMsg(
var ( gomail.WithMIMEVersion(gomail.Mime10),
client *smtp.Client gomail.WithBoundary(utils.RandomString(30, utils.CharSetAlphaNumeric, true)),
conn net.Conn
dialer = &net.Dialer{Timeout: n.config.Timeout}
) )
n.log.Debugf("Notifier SMTP client attempting connection to %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 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.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 { switch {
case err == nil: case n.config.DisableHTMLEmails:
break if err = msg.SetBodyTextTemplate(et.Text, data); err != nil {
case errors.Is(err, io.EOF): return fmt.Errorf("notifier: smtp: failed to set body: text template errored: %w", err)
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) }
default: default:
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)
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
} }
n.log.Debug("Notifier SMTP STARTTLS completed without error") if err = msg.AddAlternativeTextTemplate(et.Text, data); err != nil {
default: return fmt.Errorf("notifier: smtp: failed to set body: text template errored: %w", err)
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)")
} }
} }
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 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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type message struct { type message struct {
@ -27,7 +28,7 @@ func doGetLinkFromLastMail(t *testing.T) string {
re := regexp.MustCompile(`<a href="(.+)" class="button">.*<\/a>`) re := regexp.MustCompile(`<a href="(.+)" class="button">.*<\/a>`)
matches := re.FindStringSubmatch(string(res)) 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] return matches[1]
} }

View File

@ -1,12 +1,14 @@
package templates package templates
const (
extText = ".txt"
extHTML = ".html"
)
// Template File Names. // Template File Names.
const ( const (
TemplateNameEmailEnvelope = "Envelope.tmpl" TemplateNameEmailIdentityVerification = "IdentityVerification"
TemplateNameEmailIdentityVerificationHTML = "IdentityVerification.html" TemplateNameEmailPasswordReset = "PasswordReset"
TemplateNameEmailIdentityVerificationTXT = "IdentityVerification.txt"
TemplateNameEmailPasswordResetHTML = "PasswordReset.html"
TemplateNameEmailPasswordResetTXT = "PasswordReset.txt"
) )
// Template Category Names. // Template Category Names.

View File

@ -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))
}

View File

@ -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
}
}

View File

@ -2,7 +2,6 @@ package templates
import ( import (
"fmt" "fmt"
"io"
) )
// New creates a new templates' provider. // New creates a new templates' provider.
@ -24,66 +23,39 @@ type Provider struct {
templates Templates templates Templates
} }
// ExecuteEmailEnvelope writes the envelope template to the given io.Writer. // GetPasswordResetEmailTemplate returns the EmailTemplate for Password Reset notifications.
func (p *Provider) ExecuteEmailEnvelope(wr io.Writer, data EmailEnvelopeValues) (err error) { func (p *Provider) GetPasswordResetEmailTemplate() (t *EmailTemplate) {
return p.templates.notification.envelope.Execute(wr, data) return p.templates.notification.passwordReset
} }
// ExecuteEmailPasswordResetTemplate writes the password reset template to the given io.Writer. // GetIdentityVerificationEmailTemplate returns the EmailTemplate for Identity Verification notifications.
func (p *Provider) ExecuteEmailPasswordResetTemplate(wr io.Writer, data EmailPasswordResetValues, format Format) (err error) { func (p *Provider) GetIdentityVerificationEmailTemplate() (t *EmailTemplate) {
return p.templates.notification.passwordReset.Get(format).Execute(wr, data) return p.templates.notification.identityVerification
}
// 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)
} }
func (p *Provider) load() (err error) { func (p *Provider) load() (err error) {
var errs []error var errs []error
if tPath, embed, data, err := readTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { if p.templates.notification.identityVerification, err = loadEmailTemplate(TemplateNameEmailIdentityVerification, 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 {
errs = append(errs, err) 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) errs = append(errs, err)
} }
if p.templates.notification.identityVerification.html, err = loadTemplate(TemplateNameEmailIdentityVerificationHTML, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil { if len(errs) != 0 {
errs = append(errs, err) 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 { err = fmt.Errorf("%v, %w", err, e)
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) 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
} }

View File

@ -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

View File

@ -1,8 +1,9 @@
package templates package templates
import ( import (
"text/template" th "html/template"
"time" "io"
tt "text/template"
) )
// Templates is the struct which holds all the *template.Template values. // 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. // NotificationTemplates are the templates for the notification system.
type NotificationTemplates struct { type NotificationTemplates struct {
envelope *template.Template passwordReset *EmailTemplate
passwordReset HTMLPlainTextTemplate identityVerification *EmailTemplate
identityVerification HTMLPlainTextTemplate
} }
// Format of a template. // Template covers shared implementations between the text and html template.Template.
type Format int type Template interface {
Execute(wr io.Writer, data any) error
// Formats. ExecuteTemplate(wr io.Writer, name string, data any) error
const ( Name() string
DefaultFormat Format = iota DefinedTemplates() string
HTMLFormat }
PlainTextFormat
)
// Config for the Provider. // Config for the Provider.
type Config struct { type Config struct {
EmailTemplatesPath string 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. // EmailPasswordResetValues are the values used for password reset templates.
type EmailPasswordResetValues struct { type EmailPasswordResetValues struct {
UUID string UUID string
@ -49,17 +53,3 @@ type EmailIdentityVerificationValues struct {
LinkURL string LinkURL string
LinkText 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
}

View File

@ -1,20 +1,14 @@
package templates package templates
import ( import (
"bytes"
"fmt" "fmt"
th "html/template"
"os" "os"
"path" "path"
"path/filepath" "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) { func templateExists(path string) (exists bool) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@ -28,9 +22,9 @@ func templateExists(path string) (exists bool) {
return true 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 != "" { if overridePath != "" {
tPath = filepath.Join(overridePath, name) tPath = filepath.Join(overridePath, name+ext)
if templateExists(tPath) { if templateExists(tPath) {
if data, err = os.ReadFile(tPath); err != nil { 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 { if data, err = embedFS.ReadFile(tPath); err != nil {
return tPath, true, nil, fmt.Errorf("failed to read embedded template '%s': %w", tPath, err) 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 return tPath, true, data, nil
} }
func parseTemplate(name, tPath string, embed bool, data []byte) (t *template.Template, err error) { func parseTextTemplate(name, tPath string, embed bool, data []byte) (t *tt.Template, err error) {
if t, err = template.New(name).Parse(string(data)); err != nil { if t, err = tt.New(name + extText).Parse(string(data)); err != nil {
if embed { if embed {
return nil, fmt.Errorf("failed to parse embedded template '%s': %w", tPath, err) 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 return t, nil
} }
//nolint:unparam func parseHTMLTemplate(name, tPath string, embed bool, data []byte) (t *th.Template, err error) {
func loadTemplate(name, category, overridePath string) (t *template.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 ( var (
embed bool embed bool
tPath string tpath string
data []byte 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 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
} }