refactor(notifier): utilize smtp lib (#4403)
This drops a whole heap of code we were maintaining in favor of a SMTP library. Closes #2678pull/4634/head
parent
38ca5f06d4
commit
0bb657e11c
|
@ -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
4
go.mod
|
@ -6,7 +6,6 @@ require (
|
|||
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
|
||||
github.com/deckarep/golang-set v1.8.0
|
||||
github.com/deckarep/golang-set/v2 v2.1.0
|
||||
github.com/duosecurity/duo_api_golang v0.0.0-20221117185402-091daa09e19d
|
||||
github.com/fasthttp/router v1.4.14
|
||||
github.com/fasthttp/session/v2 v2.4.13
|
||||
|
@ -40,6 +39,8 @@ require (
|
|||
github.com/stretchr/testify v1.8.1
|
||||
github.com/trustelem/zxcvbn v1.0.1
|
||||
github.com/valyala/fasthttp v1.43.0
|
||||
github.com/wneessen/go-mail v0.3.5
|
||||
golang.org/x/net v0.1.0
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.3.0
|
||||
golang.org/x/text v0.5.0
|
||||
|
@ -109,7 +110,6 @@ require (
|
|||
github.com/ysmood/leakless v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.1.0 // indirect
|
||||
golang.org/x/mod v0.6.0 // indirect
|
||||
golang.org/x/net v0.1.0 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/tools v0.2.0 // indirect
|
||||
|
|
3
go.sum
3
go.sum
|
@ -115,7 +115,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
|
||||
github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
|
||||
github.com/deckarep/golang-set/v2 v2.1.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/dgraph-io/ristretto v0.0.1/go.mod h1:T40EBc7CJke8TkpiYfGGKAeFjSaxuFXhuXRyumBd6RE=
|
||||
github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
|
@ -610,6 +609,8 @@ github.com/valyala/fasthttp v1.42.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seB
|
|||
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
|
||||
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/wneessen/go-mail v0.3.5 h1:5fl4O1SnBpA072WFD+q1KBX6L3ltiIsKQDYjs7sY7GM=
|
||||
github.com/wneessen/go-mail v0.3.5/go.mod h1:m25lkU2GYQnlVr6tdwK533/UXxo57V0kLOjaFYmub0E=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue