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

4
go.mod
View File

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

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/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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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"
"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 href="(.+)" class="button">.*<\/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]
}

View File

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

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 (
"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
}

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

View File

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