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