fix(notification): incorrect date header format (#3684)

* fix(notification): incorrect date header format

The date header in the email envelopes was incorrectly formatted missing a space between the `Date:` header and the value of this header. This also refactors the notification templates system allowing people to manually override the envelope itself.

* test: fix tests and linting issues

* fix: misc issues

* refactor: misc refactoring

* docs: add example for envelope with message id

* refactor: organize smtp notifier

* refactor: move subject interpolation

* refactor: include additional placeholders

* docs: fix missing link

* docs: gravity

* fix: rcpt to command

* refactor: remove mid

* refactor: apply suggestions

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>

* refactor: include pid

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
pull/3718/head
James Elliott 2022-07-18 10:56:09 +10:00 committed by GitHub
parent 38e3f741b7
commit df016be29e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 627 additions and 349 deletions

View File

@ -56,7 +56,9 @@ host: "[fd00:1111:2222:3333::1]"
{{< confkey type="integer" required="yes" >}} {{< confkey type="integer" required="yes" >}}
The port the SMTP service is listening on. The port the SMTP service is listening on. Port 465 is treated as a special port where the entire connection is over
TLS. This port was formerly known as the SMTPS port but is now known as the SUBMISSIONS port i.e. SUBMISSION Secure. All
other ports expect to perform a STARTTLS negotiation.
### timeout ### timeout

View File

@ -115,7 +115,7 @@ This section documents the usage.
{{< confkey type="string" required="no" >}} {{< confkey type="string" required="no" >}}
The key `server_name` overrides the name checked against the certificate in the verification process. Useful if you The key `server_name` overrides the name checked against the certificate in the verification process. Useful if you
require to use a direct IP address for the address of the backend service but want to verify a specific SNI. require an IP address for the host of the backend service but want to verify a specific certificate server name.
### skip_verify ### skip_verify

View File

@ -58,7 +58,38 @@ 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
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:
In template files, you can use the following placeholders which are automatically injected into the templates:
| 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. |
| `{{ .Boundary }}` | The random alphanumeric 20 character multi-part email boundary. |
| `{{ .Body.PlainText }}` | The plain text version of the email. |
| `{{ .Body.HTML }}` | The HTML version of the email. |
## Original Templates ## Original Templates
The original template content can be found on The original template content can be found on
[GitHub](https://github.com/authelia/authelia/tree/master/internal/templates). [GitHub](https://github.com/authelia/authelia/tree/master/internal/templates/src/notification).
[host]: ../../configuration/notifications/smtp.md#host
[server_name]: ../../configuration/notifications/smtp.md#tls
[sender]: ../../configuration/notifications/smtp.md#sender
[identifier]: ../../configuration/notifications/smtp.md#identifier

View File

@ -0,0 +1,21 @@
From: {{ .From }}
To: {{ .To }}
Subject: {{ .Subject }}
Date: {{ .Date.Format "Mon, 2 Jan 2006 15:04:05 -0700" }}
Message-ID: <{{ .UUID }}@{{ .Identifier }}>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="{{ .Boundary }}"
--{{ .Boundary }}
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
{{ .Body.PlainText }}
{{- if not (eq .Body.HTML "") }}
--{{ .Boundary }}
Content-Type: text/html; charset="utf-8"
{{ .Body.HTML }}
{{- end }}
--{{ .Boundary }}--

View File

@ -2,6 +2,7 @@ package authentication
import ( import (
"crypto/tls" "crypto/tls"
"net/mail"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
@ -36,6 +37,24 @@ type UserDetails struct {
Groups []string Groups []string
} }
// Addresses returns the Emails []string as []mail.Address formatted with DisplayName as the Name attribute.
func (d UserDetails) Addresses() (addresses []mail.Address) {
if len(d.Emails) == 0 {
return nil
}
addresses = make([]mail.Address, len(d.Emails))
for i, email := range d.Emails {
addresses[i] = mail.Address{
Name: d.DisplayName,
Address: email,
}
}
return addresses
}
type ldapUserProfile struct { type ldapUserProfile struct {
DN string DN string
Emails []string Emails []string

View File

@ -11,6 +11,7 @@ import (
"github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/storage" "github.com/authelia/authelia/v4/internal/storage"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/totp" "github.com/authelia/authelia/v4/internal/totp"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
@ -49,11 +50,16 @@ func getProviders() (providers middlewares.Providers, warnings []error, errors [
userProvider = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool) userProvider = authentication.NewLDAPUserProvider(config.AuthenticationBackend, autheliaCertPool)
} }
templatesProvider, err := templates.New(templates.Config{EmailTemplatesPath: config.Notifier.TemplatePath})
if err != nil {
errors = append(errors, err)
}
var notifier notification.Notifier var notifier notification.Notifier
switch { switch {
case config.Notifier.SMTP != nil: case config.Notifier.SMTP != nil:
notifier = notification.NewSMTPNotifier(config.Notifier.SMTP, autheliaCertPool) notifier = notification.NewSMTPNotifier(config.Notifier.SMTP, autheliaCertPool, templatesProvider)
case config.Notifier.FileSystem != nil: case config.Notifier.FileSystem != nil:
notifier = notification.NewFileNotifier(*config.Notifier.FileSystem) notifier = notification.NewFileNotifier(*config.Notifier.FileSystem)
} }
@ -89,6 +95,7 @@ func getProviders() (providers middlewares.Providers, warnings []error, errors [
NTP: ntpProvider, NTP: ntpProvider,
Notifier: notifier, Notifier: notifier,
SessionProvider: sessionProvider, SessionProvider: sessionProvider,
Templates: templatesProvider,
TOTP: totpProvider, TOTP: totpProvider,
PasswordPolicy: ppolicyProvider, PasswordPolicy: ppolicyProvider,
}, warnings, errors }, warnings, errors

View File

@ -20,7 +20,7 @@ type SMTPNotifierConfiguration struct {
Identifier string `koanf:"identifier"` Identifier string `koanf:"identifier"`
Sender mail.Address `koanf:"sender"` Sender mail.Address `koanf:"sender"`
Subject string `koanf:"subject"` Subject string `koanf:"subject"`
StartupCheckAddress string `koanf:"startup_check_address"` StartupCheckAddress mail.Address `koanf:"startup_check_address"`
DisableRequireTLS bool `koanf:"disable_require_tls"` DisableRequireTLS bool `koanf:"disable_require_tls"`
DisableHTMLEmails bool `koanf:"disable_html_emails"` DisableHTMLEmails bool `koanf:"disable_html_emails"`
TLS *TLSConfig `koanf:"tls"` TLS *TLSConfig `koanf:"tls"`
@ -39,7 +39,7 @@ var DefaultSMTPNotifierConfiguration = SMTPNotifierConfiguration{
Timeout: time.Second * 5, Timeout: time.Second * 5,
Subject: "[Authelia] {title}", Subject: "[Authelia] {title}",
Identifier: "localhost", Identifier: "localhost",
StartupCheckAddress: "test@authelia.com", StartupCheckAddress: mail.Address{Name: "Authelia Test", Address: "test@authelia.com"},
TLS: &TLSConfig{ TLS: &TLSConfig{
MinimumVersion: "TLS1.2", MinimumVersion: "TLS1.2",
}, },

View File

@ -54,7 +54,6 @@ const (
"is configured" "is configured"
errFmtNotifierTemplatePathNotExist = "notifier: option 'template_path' refers to location '%s' which does not exist" errFmtNotifierTemplatePathNotExist = "notifier: option 'template_path' refers to location '%s' which does not exist"
errFmtNotifierTemplatePathUnknownError = "notifier: option 'template_path' refers to location '%s' which couldn't be opened: %w" errFmtNotifierTemplatePathUnknownError = "notifier: option 'template_path' refers to location '%s' which couldn't be opened: %w"
errFmtNotifierTemplateLoad = "notifier: error loading template '%s': %w"
errFmtNotifierFileSystemFileNameNotConfigured = "notifier: filesystem: option 'filename' is required" errFmtNotifierFileSystemFileNameNotConfigured = "notifier: filesystem: option 'filename' is required"
errFmtNotifierSMTPNotConfigured = "notifier: smtp: option '%s' is required" errFmtNotifierSMTPNotConfigured = "notifier: smtp: option '%s' is required"
) )

View File

@ -3,11 +3,8 @@ package validator
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"text/template"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/templates"
) )
// ValidateNotifier validates and update notifier configuration. // ValidateNotifier validates and update notifier configuration.
@ -42,7 +39,6 @@ func validateNotifierTemplates(config *schema.NotifierConfiguration, validator *
var ( var (
err error err error
t *template.Template
) )
_, err = os.Stat(config.TemplatePath) _, err = os.Stat(config.TemplatePath)
@ -55,34 +51,10 @@ func validateNotifierTemplates(config *schema.NotifierConfiguration, validator *
validator.Push(fmt.Errorf(errFmtNotifierTemplatePathUnknownError, config.TemplatePath, err)) validator.Push(fmt.Errorf(errFmtNotifierTemplatePathUnknownError, config.TemplatePath, err))
return return
} }
if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameIdentityVerification+".html")); err == nil {
templates.EmailIdentityVerificationHTML = t
} else {
validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameIdentityVerification+".html", err))
}
if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameIdentityVerification+".txt")); err == nil {
templates.EmailIdentityVerificationPlainText = t
} else {
validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameIdentityVerification+".txt", err))
}
if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameBasic+".html")); err == nil {
templates.EmailPasswordResetHTML = t
} else {
validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameBasic+".html", err))
}
if t, err = template.ParseFiles(filepath.Join(config.TemplatePath, templates.TemplateNameBasic+".txt")); err == nil {
templates.EmailPasswordResetPlainText = t
} else {
validator.PushWarning(fmt.Errorf(errFmtNotifierTemplateLoad, templates.TemplateNameBasic+".txt", err))
}
} }
func validateSMTPNotifier(config *schema.SMTPNotifierConfiguration, validator *schema.StructValidator) { func validateSMTPNotifier(config *schema.SMTPNotifierConfiguration, validator *schema.StructValidator) {
if config.StartupCheckAddress == "" { if config.StartupCheckAddress.Address == "" {
config.StartupCheckAddress = schema.DefaultSMTPNotifierConfiguration.StartupCheckAddress config.StartupCheckAddress = schema.DefaultSMTPNotifierConfiguration.StartupCheckAddress
} }

View File

@ -82,6 +82,17 @@ func (suite *NotifierSuite) TestSMTPShouldSetTLSDefaults() {
suite.Assert().False(suite.config.SMTP.TLS.SkipVerify) suite.Assert().False(suite.config.SMTP.TLS.SkipVerify)
} }
func (suite *NotifierSuite) TestSMTPShouldDefaultStartupCheckAddress() {
suite.Assert().Equal(mail.Address{Name: "", Address: ""}, suite.config.SMTP.StartupCheckAddress)
ValidateNotifier(&suite.config, suite.validator)
suite.Assert().Len(suite.validator.Warnings(), 0)
suite.Assert().Len(suite.validator.Errors(), 0)
suite.Assert().Equal(mail.Address{Name: "Authelia Test", Address: "test@authelia.com"}, suite.config.SMTP.StartupCheckAddress)
}
func (suite *NotifierSuite) TestSMTPShouldDefaultTLSServerNameToHost() { func (suite *NotifierSuite) TestSMTPShouldDefaultTLSServerNameToHost() {
suite.config.SMTP.Host = "google.com" suite.config.SMTP.Host = "google.com"
suite.config.SMTP.TLS = &schema.TLSConfig{ suite.config.SMTP.TLS = &schema.TLSConfig{

View File

@ -81,16 +81,14 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
} }
data := map[string]interface{}{ 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(),
} }
if !disableHTML { if !disableHTML {
err = templates.EmailPasswordResetHTML.Execute(bufHTML, data) if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufHTML, values, templates.HTMLFormat); err != nil {
if err != nil {
ctx.Logger.Error(err) ctx.Logger.Error(err)
ctx.ReplyOK() ctx.ReplyOK()
@ -100,17 +98,19 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
bufText := new(bytes.Buffer) bufText := new(bytes.Buffer)
if err = templates.EmailPasswordResetPlainText.Execute(bufText, data); err != nil { if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufText, values, templates.PlainTextFormat); err != nil {
ctx.Logger.Error(err) ctx.Logger.Error(err)
ctx.ReplyOK() ctx.ReplyOK()
return return
} }
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, userInfo.Emails[0]) username, addresses[0])
if err = ctx.Providers.Notifier.Send(userInfo.Emails[0], "Password changed successfully", bufText.String(), bufHTML.String()); err != nil { if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.String(), bufHTML.String()); err != nil {
ctx.Logger.Error(err) ctx.Logger.Error(err)
ctx.ReplyOK() ctx.ReplyOK()

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/mail"
"time" "time"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
@ -76,16 +77,16 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
} }
data := map[string]interface{}{ values := templates.EmailIdentityVerificationValues{
"Title": args.MailTitle, Title: args.MailTitle,
"LinkURL": link, LinkURL: link,
"LinkText": args.MailButtonContent, LinkText: args.MailButtonContent,
"DisplayName": identity.DisplayName, DisplayName: identity.DisplayName,
"RemoteIP": ctx.RemoteIP().String(), RemoteIP: ctx.RemoteIP().String(),
} }
if !disableHTML { if !disableHTML {
if err = templates.EmailIdentityVerificationHTML.Execute(bufHTML, data); err != nil { if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufHTML, values, templates.HTMLFormat); err != nil {
ctx.Error(err, messageOperationFailed) ctx.Error(err, messageOperationFailed)
return return
} }
@ -93,7 +94,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
bufText := new(bytes.Buffer) bufText := new(bytes.Buffer)
if err = templates.EmailIdentityVerificationPlainText.Execute(bufText, data); err != nil { if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufText, values, templates.PlainTextFormat); err != nil {
ctx.Error(err, messageOperationFailed) ctx.Error(err, messageOperationFailed)
return return
} }
@ -101,7 +102,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
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(identity.Email, args.MailTitle, bufText.String(), bufHTML.String()); err != nil { if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.String(), bufHTML.String()); err != nil {
ctx.Error(err, messageOperationFailed) ctx.Error(err, messageOperationFailed)
return return
} }

View File

@ -2,6 +2,7 @@ package middlewares_test
import ( import (
"fmt" "fmt"
"net/mail"
"testing" "testing"
"time" "time"
@ -80,7 +81,7 @@ func TestShouldFailSendingAnEmail(t *testing.T) {
Return(nil) Return(nil)
mock.NotifierMock.EXPECT(). mock.NotifierMock.EXPECT().
Send(gomock.Eq("john@example.com"), gomock.Eq("Title"), gomock.Any(), gomock.Any()). Send(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)
@ -120,7 +121,7 @@ func TestShouldSucceedIdentityVerificationStartProcess(t *testing.T) {
Return(nil) Return(nil)
mock.NotifierMock.EXPECT(). mock.NotifierMock.EXPECT().
Send(gomock.Eq("john@example.com"), gomock.Eq("Title"), gomock.Any(), gomock.Any()). Send(gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()).
Return(nil) Return(nil)
args := newArgs(defaultRetriever) args := newArgs(defaultRetriever)

View File

@ -14,6 +14,7 @@ import (
"github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/storage" "github.com/authelia/authelia/v4/internal/storage"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/totp" "github.com/authelia/authelia/v4/internal/totp"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
@ -40,6 +41,7 @@ type Providers struct {
UserProvider authentication.UserProvider UserProvider authentication.UserProvider
StorageProvider storage.Provider StorageProvider storage.Provider
Notifier notification.Notifier Notifier notification.Notifier
Templates *templates.Provider
TOTP totp.Provider TOTP totp.Provider
PasswordPolicy PasswordPolicyProvider PasswordPolicy PasswordPolicyProvider
} }

View File

@ -18,6 +18,7 @@ import (
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/templates"
) )
// MockAutheliaCtx a mock of AutheliaCtx. // MockAutheliaCtx a mock of AutheliaCtx.
@ -115,6 +116,12 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
mockAuthelia.TOTPMock = NewMockTOTP(mockAuthelia.Ctrl) mockAuthelia.TOTPMock = NewMockTOTP(mockAuthelia.Ctrl)
providers.TOTP = mockAuthelia.TOTPMock providers.TOTP = mockAuthelia.TOTPMock
var err error
if providers.Templates, err = templates.New(templates.Config{}); err != nil {
panic(err)
}
request := &fasthttp.RequestCtx{} request := &fasthttp.RequestCtx{}
// Set a cookie to identify this client throughout the test. // Set a cookie to identify this client throughout the test.
// request.Request.Header.SetCookie("authelia_session", "client_cookie"). // request.Request.Header.SetCookie("authelia_session", "client_cookie").

View File

@ -5,6 +5,7 @@
package mocks package mocks
import ( import (
mail "net/mail"
reflect "reflect" reflect "reflect"
gomock "github.com/golang/mock/gomock" gomock "github.com/golang/mock/gomock"
@ -34,7 +35,7 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder {
} }
// Send mocks base method. // Send mocks base method.
func (m *MockNotifier) Send(arg0, arg1, arg2, arg3 string) error { func (m *MockNotifier) Send(arg0 mail.Address, arg1, arg2, arg3 string) 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)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)

View File

@ -5,5 +5,20 @@ const (
) )
const ( const (
rfc5322DateTimeLayout = "Mon, 2 Jan 2006 15:04:05 -0700" smtpAUTHMechanismPlain = "PLAIN"
smtpAUTHMechanismLogin = "LOGIN"
smtpPortSUBMISSIONS = 465
smtpCommandDATA = "DATA"
smtpCommandHELLO = "EHLO/HELO"
smtpCommandSTARTTLS = "STARTTLS"
smtpCommandAUTH = "AUTH"
smtpCommandMAIL = "MAIL"
smtpCommandRCPT = "RCPT"
)
const (
fmtSMTPGenericError = "error performing %s with the SMTP server: %w"
fmtSMTPDialError = "error dialing the SMTP server: %w"
) )

View File

@ -2,6 +2,7 @@ package notification
import ( import (
"fmt" "fmt"
"net/mail"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -42,7 +43,7 @@ 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, subject, body, _ string) error { func (n *FileNotifier) Send(recipient mail.Address, subject, body, _ string) error {
content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, body) content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, body)
return os.WriteFile(n.path, []byte(content), fileNotifierMode) return os.WriteFile(n.path, []byte(content), fileNotifierMode)

View File

@ -1,6 +1,8 @@
package notification package notification
import ( import (
"net/mail"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
) )
@ -8,5 +10,5 @@ import (
type Notifier interface { type Notifier interface {
model.StartupCheck model.StartupCheck
Send(recipient, subject, body, htmlBody string) (err error) Send(recipient mail.Address, subject, body, htmlBody string) (err error)
} }

View File

@ -26,7 +26,7 @@ func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "", nil, errors.New("unexpected hostname from server") return "", nil, errors.New("unexpected hostname from server")
} }
return "LOGIN", []byte{}, nil return smtpAUTHMechanismLogin, []byte{}, nil
} }
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {

View File

@ -20,7 +20,7 @@ func TestFullLoginAuth(t *testing.T) {
auth := newLoginAuth(username, password, "mail.authelia.com") auth := newLoginAuth(username, password, "mail.authelia.com")
proto, _, err := auth.Start(serverInfo) proto, _, err := auth.Start(serverInfo)
assert.Equal(t, "LOGIN", proto) assert.Equal(t, smtpAUTHMechanismLogin, proto)
require.NoError(t, err) require.NoError(t, err)
toServer, err := auth.Next([]byte("Username:"), true) toServer, err := auth.Next([]byte("Username:"), true)

View File

@ -7,36 +7,152 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"net/mail"
"net/smtp" "net/smtp"
"os"
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"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"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
// SMTPNotifier a notifier to send emails to SMTP servers. // NewSMTPNotifier creates a SMTPNotifier using the notifier configuration.
type SMTPNotifier struct { func NewSMTPNotifier(config *schema.SMTPNotifierConfiguration, certPool *x509.CertPool, templateProvider *templates.Provider) *SMTPNotifier {
configuration *schema.SMTPNotifierConfiguration notifier := &SMTPNotifier{
client *smtp.Client config: config,
tlsConfig *tls.Config tlsConfig: utils.NewTLSConfig(config.TLS, tls.VersionTLS12, certPool),
log *logrus.Logger log: logging.Logger(),
templates: templateProvider,
} }
// NewSMTPNotifier creates a SMTPNotifier using the notifier configuration. at := strings.LastIndex(config.Sender.Address, "@")
func NewSMTPNotifier(configuration *schema.SMTPNotifierConfiguration, certPool *x509.CertPool) *SMTPNotifier {
notifier := &SMTPNotifier{ if at >= 0 {
configuration: configuration, notifier.domain = config.Sender.Address[at:]
tlsConfig: utils.NewTLSConfig(configuration.TLS, tls.VersionTLS12, certPool),
log: logging.Logger(),
} }
return notifier return notifier
} }
// 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
}
// Send is used to email a recipient.
func (n *SMTPNotifier) Send(recipient mail.Address, title, body, htmlBody string) (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, title, body, htmlBody); 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)
}
// Always execute QUIT at the end once we're connected.
defer n.cleanup()
if err = n.preamble(n.config.StartupCheckAddress); err != nil {
return 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)
}
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}
)
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))
}
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)
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). // Do startTLS if available (some servers only provide the auth extension after, and encryption is preferred).
func (n *SMTPNotifier) startTLS() error { func (n *SMTPNotifier) startTLS() error {
// Only start if not already encrypted. // Only start if not already encrypted.
@ -55,11 +171,11 @@ func (n *SMTPNotifier) startTLS() error {
n.log.Debug("Notifier SMTP STARTTLS completed without error") n.log.Debug("Notifier SMTP STARTTLS completed without error")
default: default:
switch n.configuration.DisableRequireTLS { switch n.config.DisableRequireTLS {
case true: 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)") 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: default:
return errors.New("Notifier SMTP server does not support TLS and it is required by default (see documentation if you want to disable this highly recommended requirement)") 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)")
} }
} }
@ -67,40 +183,44 @@ func (n *SMTPNotifier) startTLS() error {
} }
// Attempt Authentication. // Attempt Authentication.
func (n *SMTPNotifier) auth() error { func (n *SMTPNotifier) auth() (err error) {
// Attempt AUTH if password is specified only. // Attempt AUTH if password is specified only.
if n.configuration.Password != "" { if n.config.Password != "" {
_, ok := n.client.TLSConnectionState() var (
if !ok { ok bool
return errors.New("Notifier SMTP client does not support authentication over plain text and the connection is currently plain text") 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. // Check the server supports AUTH, and get the mechanisms.
ok, m := n.client.Extension("AUTH") if ok, m = n.client.Extension(smtpCommandAUTH); ok {
if ok {
var auth smtp.Auth var auth smtp.Auth
n.log.Debugf("Notifier SMTP server supports authentication with the following mechanisms: %s", m) n.log.Debugf("Notifier SMTP server supports authentication with the following mechanisms: %s", m)
mechanisms := strings.Split(m, " ") mechanisms := strings.Split(m, " ")
// Adaptively select the AUTH mechanism to use based on what the server advertised. // Adaptively select the AUTH mechanism to use based on what the server advertised.
if utils.IsStringInSlice("PLAIN", mechanisms) { if utils.IsStringInSlice(smtpAUTHMechanismPlain, mechanisms) {
auth = smtp.PlainAuth("", n.configuration.Username, n.configuration.Password, n.configuration.Host) auth = smtp.PlainAuth("", n.config.Username, n.config.Password, n.config.Host)
n.log.Debug("Notifier SMTP client attempting AUTH PLAIN with server") n.log.Debug("Notifier SMTP client attempting AUTH PLAIN with server")
} else if utils.IsStringInSlice("LOGIN", mechanisms) { } else if utils.IsStringInSlice(smtpAUTHMechanismLogin, mechanisms) {
auth = newLoginAuth(n.configuration.Username, n.configuration.Password, n.configuration.Host) auth = newLoginAuth(n.config.Username, n.config.Password, n.config.Host)
n.log.Debug("Notifier SMTP client attempting AUTH LOGIN with server") n.log.Debug("Notifier SMTP client attempting AUTH LOGIN with server")
} }
// Throw error since AUTH extension is not supported. // Throw error since AUTH extension is not supported.
if auth == nil { if auth == nil {
return fmt.Errorf("notifier SMTP server does not advertise a AUTH mechanism that are supported by Authelia (PLAIN or LOGIN are supported, but server advertised %s mechanisms)", m) 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. // Authenticate.
if err := n.client.Auth(auth); err != nil { if err = n.client.Auth(auth); err != nil {
return err return err
} }
@ -109,7 +229,7 @@ func (n *SMTPNotifier) auth() error {
return nil return nil
} }
return errors.New("Notifier SMTP 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") 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") n.log.Debug("Notifier SMTP config has no password specified so authentication is being skipped")
@ -117,182 +237,67 @@ func (n *SMTPNotifier) auth() error {
return nil return nil
} }
func (n *SMTPNotifier) compose(recipient, subject, body, htmlBody string) error { func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody string) (err error) {
n.log.Debugf("Notifier SMTP client attempting to send email body to %s", recipient) n.log.Debugf("Notifier SMTP client attempting to send email body to %s", recipient.String())
if !n.configuration.DisableRequireTLS { if !n.config.DisableRequireTLS {
_, ok := n.client.TLSConnectionState() _, ok := n.client.TLSConnectionState()
if !ok { if !ok {
return errors.New("Notifier SMTP client can't send an email over plain text connection") return errors.New("client can't send an email over plain text connection")
} }
} }
wc, err := n.client.Data()
if err != nil {
n.log.Debugf("Notifier SMTP client error while obtaining WriteCloser: %s", err)
return err
}
boundary := utils.RandomString(30, utils.AlphaNumericCharacters, true)
now := time.Now()
msg := "Date:" + now.Format(rfc5322DateTimeLayout) + "\n" +
"From: " + n.configuration.Sender.String() + "\n" +
"To: " + recipient + "\n" +
"Subject: " + subject + "\n" +
"MIME-version: 1.0\n" +
"Content-Type: multipart/alternative; boundary=" + boundary + "\n\n" +
"--" + boundary + "\n" +
"Content-Type: text/plain; charset=\"UTF-8\"\n" +
"Content-Transfer-Encoding: quoted-printable\n" +
"Content-Disposition: inline\n\n" +
body + "\n"
if htmlBody != "" {
msg += "--" + boundary + "\n" +
"Content-Type: text/html; charset=\"UTF-8\"\n\n" +
htmlBody + "\n"
}
msg += "--" + boundary + "--"
_, err = fmt.Fprint(wc, msg)
if err != nil {
n.log.Debugf("Notifier SMTP client error while sending email body over WriteCloser: %s", err)
return err
}
err = wc.Close()
if err != nil {
n.log.Debugf("Notifier SMTP client error while closing the WriteCloser: %s", err)
return err
}
return nil
}
// Dial the SMTP server with the SMTPNotifier config.
func (n *SMTPNotifier) dial() (err error) {
var ( var (
client *smtp.Client wc io.WriteCloser
conn net.Conn muuid uuid.UUID
dialer = &net.Dialer{Timeout: n.configuration.Timeout}
) )
n.log.Debugf("Notifier SMTP client attempting connection to %s:%d", n.configuration.Host, n.configuration.Port) if wc, err = n.client.Data(); err != nil {
n.log.Debugf("Notifier SMTP client error while obtaining WriteCloser: %v", err)
if n.configuration.Port == 465 {
n.log.Infof("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.configuration.Host, n.configuration.Port), n.tlsConfig)
} else {
conn, err = dialer.Dial("tcp", fmt.Sprintf("%s:%d", n.configuration.Host, n.configuration.Port))
}
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)
default:
return err return err
} }
client, err = smtp.NewClient(conn, n.configuration.Host) if muuid, err = uuid.NewRandom(); err != nil {
if err != nil {
return err return err
} }
n.client = client values := 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}", title),
Date: time.Now(),
Boundary: utils.RandomString(30, utils.AlphaNumericCharacters, true),
Body: templates.EmailEnvelopeBodyValues{
PlainText: body,
HTML: htmlBody,
},
}
n.log.Debug("Notifier SMTP client connected successfully") if err = n.templates.ExecuteEmailEnvelope(wc, values); err != nil {
n.log.Debugf("Notifier SMTP client error while sending email body over WriteCloser: %v", err)
return err
}
if err = wc.Close(); err != nil {
n.log.Debugf("Notifier SMTP client error while closing the WriteCloser: %v", err)
return err
}
return nil return nil
} }
// Closes the connection properly. // Closes the connection properly.
func (n *SMTPNotifier) cleanup() { func (n *SMTPNotifier) cleanup() {
err := n.client.Quit() if err := n.client.Quit(); err != nil {
if err != nil { n.log.Warnf("Notifier SMTP client encountered error during cleanup: %v", err)
n.log.Warnf("Notifier SMTP client encountered error during cleanup: %s", err)
}
} }
// StartupCheck implements the startup check provider interface. n.client = nil
func (n *SMTPNotifier) StartupCheck() (err error) {
if err = n.dial(); err != nil {
return fmt.Errorf("error dialing the smtp server: %w", err)
}
defer n.cleanup()
if err = n.client.Hello(n.configuration.Identifier); err != nil {
return fmt.Errorf("error performing HELO/EHLO with the smtp server: %w", err)
}
if err = n.startTLS(); err != nil {
return fmt.Errorf("error performing STARTTLS with the smtp server: %w", err)
}
if err = n.auth(); err != nil {
return fmt.Errorf("error performing AUTH with the smtp server: %w", err)
}
if err = n.client.Mail(n.configuration.Sender.Address); err != nil {
return fmt.Errorf("error performing MAIL FROM with the smtp server: %w", err)
}
if err = n.client.Rcpt(n.configuration.StartupCheckAddress); err != nil {
return fmt.Errorf("error performing RCPT with the smtp server: %w", err)
}
return n.client.Reset()
}
// Send is used to send an email to a recipient.
func (n *SMTPNotifier) Send(recipient, title, body, htmlBody string) error {
subject := strings.ReplaceAll(n.configuration.Subject, "{title}", title)
var err error
if err = n.dial(); err != nil {
return fmt.Errorf("error dialing the smtp server: %w", err)
}
// Always execute QUIT at the end once we're connected.
defer n.cleanup()
if err = n.client.Hello(n.configuration.Identifier); err != nil {
return fmt.Errorf("error performing HELO/EHLO with the smtp server: %w", err)
}
if err = n.startTLS(); err != nil {
return fmt.Errorf("error performing STARTTLS with the smtp server: %w", err)
}
if err = n.auth(); err != nil {
return fmt.Errorf("error performing AUTH with the smtp server: %w", err)
}
if err = n.client.Mail(n.configuration.Sender.Address); err != nil {
n.log.Debugf("Notifier SMTP failed while sending MAIL FROM (using sender) with error: %s", err)
return fmt.Errorf("error performing MAIL FROM with the smtp server: %w", err)
}
if err = n.client.Rcpt(n.configuration.StartupCheckAddress); err != nil {
n.log.Debugf("Notifier SMTP failed while sending RCPT TO (using recipient) with error: %s", err)
return fmt.Errorf("error performing RCPT with the smtp server: %w", err)
}
// Compose and send the email body to the server.
if err = n.compose(recipient, subject, body, htmlBody); err != nil {
return err
}
n.log.Debug("Notifier SMTP client successfully sent email")
return nil
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/templates"
) )
func TestShouldConfigureSMTPNotifierWithTLS11(t *testing.T) { func TestShouldConfigureSMTPNotifierWithTLS11(t *testing.T) {
@ -22,7 +23,7 @@ func TestShouldConfigureSMTPNotifierWithTLS11(t *testing.T) {
}, },
} }
notifier := NewSMTPNotifier(config.SMTP, nil) notifier := NewSMTPNotifier(config.SMTP, nil, &templates.Provider{})
assert.Equal(t, "smtp.example.com", notifier.tlsConfig.ServerName) assert.Equal(t, "smtp.example.com", notifier.tlsConfig.ServerName)
assert.Equal(t, uint16(tls.VersionTLS11), notifier.tlsConfig.MinVersion) assert.Equal(t, uint16(tls.VersionTLS11), notifier.tlsConfig.MinVersion)
@ -41,7 +42,7 @@ func TestShouldConfigureSMTPNotifierWithServerNameOverrideAndDefaultTLS12(t *tes
}, },
} }
notifier := NewSMTPNotifier(config.SMTP, nil) notifier := NewSMTPNotifier(config.SMTP, nil, &templates.Provider{})
assert.Equal(t, "smtp.golang.org", notifier.tlsConfig.ServerName) assert.Equal(t, "smtp.golang.org", notifier.tlsConfig.ServerName)
assert.Equal(t, uint16(tls.VersionTLS12), notifier.tlsConfig.MinVersion) assert.Equal(t, uint16(tls.VersionTLS12), notifier.tlsConfig.MinVersion)

View File

@ -2,6 +2,14 @@ package templates
// Template File Names. // Template File Names.
const ( const (
TemplateNameBasic = "Basic" TemplateNameEmailEnvelope = "Envelope.tmpl"
TemplateNameIdentityVerification = "IdentityVerification" TemplateNameEmailIdentityVerificationHTML = "IdentityVerification.html"
TemplateNameEmailIdentityVerificationTXT = "IdentityVerification.txt"
TemplateNameEmailPasswordResetHTML = "PasswordReset.html"
TemplateNameEmailPasswordResetTXT = "PasswordReset.txt"
)
// Template Category Names.
const (
TemplateCategoryNotifications = "notification"
) )

View File

@ -1,28 +0,0 @@
package templates
import (
"text/template"
)
// EmailIdentityVerificationPlainText the template of email that the user will receive for identity verification.
var EmailIdentityVerificationPlainText *template.Template
func init() {
t, err := template.New("email_identity_verification_plain_text").Parse(emailContentIdentityVerificationPlainText)
if err != nil {
panic(err)
}
EmailIdentityVerificationPlainText = t
}
const emailContentIdentityVerificationPlainText = `
This email has been sent to you in order to validate your identity.
If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator.
To setup your 2FA please visit the following URL: {{ .LinkURL }}
This email was generated by a user with the IP {{ .RemoteIP }}.
Please contact an administrator if you did not initiate this process.
`

View File

@ -1,26 +0,0 @@
package templates
import (
"text/template"
)
// EmailPasswordResetPlainText the template of email that the user will receive for identity verification.
var EmailPasswordResetPlainText *template.Template
func init() {
t, err := template.New("email_password_reset_plain_text").Parse(emailContentBasicPlainText)
if err != nil {
panic(err)
}
EmailPasswordResetPlainText = t
}
const emailContentBasicPlainText = `
Your password has been successfully reset.
If you did not initiate the process your credentials might have been compromised. You should reset your password and contact an administrator.
This email was generated by a user with the IP {{ .RemoteIP }}.
Please contact an administrator if you did not initiate this process.
`

View File

@ -0,0 +1,8 @@
package templates
import (
"embed"
)
//go:embed src/*
var embedFS embed.FS

View File

@ -0,0 +1,23 @@
package templates
import (
"text/template"
)
// HTMLPlainTextTemplate is the template type which contains both the html and txt versions of a template.
type HTMLPlainTextTemplate struct {
html *template.Template
txt *template.Template
}
// Get returns the appropriate template given the format.
func (f HTMLPlainTextTemplate) Get(format Format) (t *template.Template) {
switch format {
case HTMLFormat:
return f.html
case PlainTextFormat:
return f.txt
default:
return f.html
}
}

View File

@ -0,0 +1,79 @@
package templates
import (
"fmt"
"io"
)
// New creates a new templates' provider.
func New(config Config) (provider *Provider, err error) {
provider = &Provider{
config: config,
}
if err = provider.load(); err != nil {
return nil, err
}
return provider, nil
}
// Provider of templates.
type Provider struct {
config Config
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)
}
// 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)
}
func (p *Provider) load() (err error) {
var errs []error
if p.templates.notification.envelope, err = loadTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil {
errs = append(errs, err)
}
if p.templates.notification.identityVerification.txt, err = loadTemplate(TemplateNameEmailIdentityVerificationTXT, TemplateCategoryNotifications, 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 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)
}
return fmt.Errorf("one or more errors occurred loading templates: %w", err)
}

View File

@ -0,0 +1,20 @@
From: {{ .From }}
To: {{ .To }}
Subject: {{ .Subject }}
Date: {{ .Date.Format "Mon, 2 Jan 2006 15:04:05 -0700" }}
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="{{ .Boundary }}"
--{{ .Boundary }}
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
{{ .Body.PlainText }}
{{- if not (eq .Body.HTML "") }}
--{{ .Boundary }}
Content-Type: text/html; charset="utf-8"
{{ .Body.HTML }}
{{- end }}
--{{ .Boundary }}--

View File

@ -1,22 +1,3 @@
package templates
import (
"text/template"
)
// EmailIdentityVerificationHTML the template of email that the user will receive for identity verification.
var EmailIdentityVerificationHTML *template.Template
func init() {
t, err := template.New("email_identity_verification_html").Parse(emailContentIdentityVerificationHTML)
if err != nil {
panic(err)
}
EmailIdentityVerificationHTML = t
}
const emailContentIdentityVerificationHTML = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"> <html xmlns="http://www.w3.org/1999/xhtml">
@ -435,4 +416,3 @@ const emailContentIdentityVerificationHTML = `
</body> </body>
</html> </html>
`

View File

@ -0,0 +1,9 @@
This email has been sent to you in order to validate your identity.
If you did not initiate the process your credentials might have been compromised and you should reset your password and contact an administrator.
To setup your 2FA please visit the following URL: {{ .LinkURL }}
This email was generated by a user with the IP {{ .RemoteIP }}.
Please contact an administrator if you did not initiate this process.

View File

@ -1,23 +1,3 @@
package templates
import (
"text/template"
)
// EmailPasswordResetHTML the template of email that the user will receive for identity verification.
var EmailPasswordResetHTML *template.Template
func init() {
t, err := template.New("email_password_reset_html").Parse(emailContentPasswordResetHTML)
if err != nil {
panic(err)
}
EmailPasswordResetHTML = t
}
//nolint:gosec // This is a template not hardcoded credentials.
const emailContentPasswordResetHTML = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"> <html xmlns="http://www.w3.org/1999/xhtml">
@ -421,4 +401,3 @@ const emailContentPasswordResetHTML = `
</body> </body>
</html> </html>
`

View File

@ -0,0 +1,7 @@
Your password has been successfully reset.
If you did not initiate the process your credentials might have been compromised and you should reset your password and contact an administrator.
This email was generated by a user with the IP {{ .RemoteIP }}.
Please contact an administrator if you did not initiate this process.

View File

@ -0,0 +1,73 @@
package templates
import (
"text/template"
"time"
)
// Templates is the struct which holds all the *template.Template values.
type Templates struct {
notification NotificationTemplates
}
// NotificationTemplates are the templates for the notification system.
type NotificationTemplates struct {
envelope *template.Template
passwordReset HTMLPlainTextTemplate
identityVerification HTMLPlainTextTemplate
}
// Format of a template.
type Format int
// Formats.
const (
DefaultFormat Format = iota
HTMLFormat
PlainTextFormat
)
// Config for the Provider.
type Config struct {
EmailTemplatesPath string
}
// EmailPasswordResetValues are the values used for password reset templates.
type EmailPasswordResetValues struct {
UUID string
Title string
DisplayName string
RemoteIP string
}
// EmailIdentityVerificationValues are the values used for the identity verification templates.
type EmailIdentityVerificationValues struct {
UUID string
Title string
DisplayName string
RemoteIP string
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
Boundary string
Body EmailEnvelopeBodyValues
}
// EmailEnvelopeBodyValues are the values used for the email envelopes bodies.
type EmailEnvelopeBodyValues struct {
PlainText string
HTML string
}

View File

@ -0,0 +1,48 @@
package templates
import (
"fmt"
"os"
"path"
"path/filepath"
"text/template"
)
func templateExists(path string) (exists bool) {
info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() {
return false
}
return true
}
//nolint:unparam
func loadTemplate(name, category, overridePath string) (t *template.Template, err error) {
if overridePath != "" {
tPath := filepath.Join(overridePath, name)
if templateExists(tPath) {
if t, err = template.ParseFiles(tPath); err != nil {
return nil, fmt.Errorf("could not parse template at path '%s': %w", tPath, err)
}
return t, nil
}
}
data, err := embedFS.ReadFile(path.Join("src", category, name))
if err != nil {
return nil, err
}
if t, err = template.New(name).Parse(string(data)); err != nil {
panic(fmt.Errorf("failed to parse internal template: %w", err))
}
return t, nil
}