fix(notification): text emails not encoded properly (#3854)

This fixes an issue where the plain text portion of emails is not encoded with quoted printable encoding.
pull/3912/head
James Elliott 2022-08-27 07:39:20 +10:00 committed by GitHub
parent 4d3ac31051
commit 319a8cf9d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 469 additions and 241 deletions

31
.gitattributes vendored 100644
View File

@ -0,0 +1,31 @@
* text=auto
*.go text
*.sh text
*.html text
*.js text
*.jsx text
*.ts text
*.tsx text
*.md text
*.json text
*.svg text
*.css text
*.yaml text
*.yml text
*.txt text
*.tmpl text
*.service text
Dockerfile text
Dockerfile.* text
LICENSE text
*.jpg binary
*.png binary
*.gif binary
*.ico binary
# RFC2822 requires us to save the email templates with CRLF line endings.
internal/templates/src/notification/* text eol=crlf
examples/templates/notifications/*.tmpl text eol=crlf
examples/templates/notifications/*.html text eol=crlf

View File

@ -18,6 +18,17 @@ two extensions; `.html` for HTML templates, and `.txt` for plaintext templates.
This guide effectively documents the usage of the
[template_path](../../configuration/notifications/introduction.md#template_path) notification configuration option.
## Important Notes
1. The templates are not covered by our stability guarantees. While we aim to avoid changes to the templates which
would cause users to have to manually change them changes may be necessary in order to facilitate bug fixes or
generally improve the templates.
1. This is especially important for the [Envelope Template](#envelope-template).
2. It is your responsibility to ensure your templates are up to date. We make no efforts in facilitating this.
2. We may not be able to offer any direct support in debugging these templates. We only offer support and fixes to
the official templates.
3. All templates __*MUST*__ be encoded in UTF-8 with CRLF line endings. The line endings __*MUST NOT*__ be a simple LF.
## Template Names
| Template | Description |
@ -60,13 +71,14 @@ Some Additional examples for specific purposes can be found in the
## 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:
In template files, you can use the following placeholders which are automatically injected into the templates:
This template contains the following placeholders which are automatically injected into the template:
| Placeholder | Description |
|:-----------------------:|:---------------------------------------------------------------------------:|
@ -80,9 +92,6 @@ In template files, you can use the following placeholders which are automaticall
| `{{ .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

View File

@ -4,18 +4,3 @@ 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

@ -11,10 +11,9 @@ import (
// NewDuoAPI create duo API instance.
func NewDuoAPI(duoAPI *duoapi.DuoApi) *APIImpl {
api := new(APIImpl)
api.DuoApi = duoAPI
return api
return &APIImpl{
DuoApi: duoAPI,
}
}
// Call call to the DuoAPI.

View File

@ -74,8 +74,6 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
return
}
bufHTML := new(bytes.Buffer)
disableHTML := false
if ctx.Configuration.Notifier.SMTP != nil {
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
@ -87,6 +85,8 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
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)
@ -96,8 +96,6 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
}
}
bufText := new(bytes.Buffer)
if err = ctx.Providers.Templates.ExecuteEmailPasswordResetTemplate(bufText, values, templates.PlainTextFormat); err != nil {
ctx.Logger.Error(err)
ctx.ReplyOK()
@ -110,7 +108,7 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) {
ctx.Logger.Debugf("Sending an email to user %s (%s) to inform that the password has changed.",
username, addresses[0])
if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.String(), bufHTML.String()); err != nil {
if err = ctx.Providers.Notifier.Send(addresses[0], "Password changed successfully", bufText.Bytes(), bufHTML.Bytes()); err != nil {
ctx.Logger.Error(err)
ctx.ReplyOK()

View File

@ -62,16 +62,15 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
return
}
uri, err := ctx.ExternalRootURL()
if err != nil {
var (
uri string
)
if uri, err = ctx.ExternalRootURL(); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
link := fmt.Sprintf("%s%s?token=%s", uri, args.TargetEndpoint, ss)
bufHTML := new(bytes.Buffer)
disableHTML := false
if ctx.Configuration.Notifier.SMTP != nil {
disableHTML = ctx.Configuration.Notifier.SMTP.DisableHTMLEmails
@ -79,12 +78,14 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
values := templates.EmailIdentityVerificationValues{
Title: args.MailTitle,
LinkURL: link,
LinkURL: fmt.Sprintf("%s%s?token=%s", uri, args.TargetEndpoint, ss),
LinkText: args.MailButtonContent,
DisplayName: identity.DisplayName,
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)
@ -92,8 +93,6 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
}
}
bufText := new(bytes.Buffer)
if err = ctx.Providers.Templates.ExecuteEmailIdentityVerificationTemplate(bufText, values, templates.PlainTextFormat); err != nil {
ctx.Error(err, messageOperationFailed)
return
@ -102,7 +101,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
ctx.Logger.Debugf("Sending an email to user %s (%s) to confirm identity for registering a device.",
identity.Username, identity.Email)
if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.String(), bufHTML.String()); err != nil {
if err = ctx.Providers.Notifier.Send(mail.Address{Name: identity.DisplayName, Address: identity.Email}, args.MailTitle, bufText.Bytes(), bufHTML.Bytes()); err != nil {
ctx.Error(err, messageOperationFailed)
return
}

View File

@ -35,7 +35,7 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder {
}
// Send mocks base method.
func (m *MockNotifier) Send(arg0 mail.Address, arg1, arg2, arg3 string) error {
func (m *MockNotifier) Send(arg0 mail.Address, arg1 string, arg2, arg3 []byte) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2, arg3)
ret0, _ := ret[0].(error)

View File

@ -16,9 +16,30 @@ const (
smtpCommandAUTH = "AUTH"
smtpCommandMAIL = "MAIL"
smtpCommandRCPT = "RCPT"
smtpEncodingQuotedPrintable = "quoted-printable"
smtpEncoding8bit = "8bit"
smtpContentTypeTextPlain = "text/plain"
smtpContentTypeTextHTML = "text/html"
smtpFmtContentType = `%s; charset="UTF-8"`
smtpFmtContentDispositionInline = "inline"
smtpExtSTARTTLS = smtpCommandSTARTTLS
smtpExt8BITMIME = "8BITMIME"
)
const (
headerContentType = "Content-Type"
headerContentDisposition = "Content-Disposition"
headerContentTransferEncoding = "Content-Transfer-Encoding"
)
const (
fmtSMTPGenericError = "error performing %s with the SMTP server: %w"
fmtSMTPDialError = "error dialing the SMTP server: %w"
)
var (
rfc2822DoubleNewLine = []byte("\r\n\r\n")
)

View File

@ -43,8 +43,8 @@ func (n *FileNotifier) StartupCheck() (err error) {
}
// Send send a identity verification link to a user.
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)
func (n *FileNotifier) Send(recipient mail.Address, subject string, bodyText, _ []byte) error {
content := fmt.Sprintf("Date: %s\nRecipient: %s\nSubject: %s\nBody: %s", time.Now(), recipient, subject, bodyText)
return os.WriteFile(n.path, []byte(content), fileNotifierMode)
}

View File

@ -10,5 +10,5 @@ import (
type Notifier interface {
model.StartupCheck
Send(recipient mail.Address, subject, body, htmlBody string) (err error)
Send(recipient mail.Address, subject string, bodyText, bodyHTML []byte) (err error)
}

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/mail"
"net/smtp"
@ -52,7 +53,7 @@ type SMTPNotifier struct {
}
// Send is used to email a recipient.
func (n *SMTPNotifier) Send(recipient mail.Address, title, body, htmlBody string) (err error) {
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)
}
@ -65,7 +66,7 @@ func (n *SMTPNotifier) Send(recipient mail.Address, title, body, htmlBody string
}
// Compose and send the email body to the server.
if err = n.compose(recipient, title, body, htmlBody); err != nil {
if err = n.compose(recipient, subject, bodyText, bodyHTML); err != nil {
return fmt.Errorf(fmtSMTPGenericError, smtpCommandDATA, err)
}
@ -161,7 +162,7 @@ func (n *SMTPNotifier) startTLS() error {
return nil
}
switch ok, _ := n.client.Extension("STARTTLS"); ok {
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)
@ -237,7 +238,7 @@ func (n *SMTPNotifier) auth() (err error) {
return nil
}
func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody string) (err error) {
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 {
@ -261,7 +262,7 @@ func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody str
return err
}
values := templates.EmailEnvelopeValues{
data := templates.EmailEnvelopeValues{
ProcessID: os.Getpid(),
UUID: muuid.String(),
Host: n.config.Host,
@ -270,21 +271,43 @@ func (n *SMTPNotifier) compose(recipient mail.Address, title, body, htmlBody str
Identifier: n.config.Identifier,
From: n.config.Sender.String(),
To: recipient.String(),
Subject: strings.ReplaceAll(n.config.Subject, "{title}", title),
Subject: strings.ReplaceAll(n.config.Subject, "{title}", subject),
Date: time.Now(),
Boundary: utils.RandomString(30, utils.AlphaNumericCharacters, true),
Body: templates.EmailEnvelopeBodyValues{
PlainText: body,
HTML: htmlBody,
},
}
if err = n.templates.ExecuteEmailEnvelope(wc, values); err != nil {
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

View File

@ -0,0 +1,96 @@
package notification
import (
"fmt"
"io"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"github.com/authelia/authelia/v4/internal/utils"
)
func smtpMIMEHeaders(ext8BITMIME bool, contentType string, data []byte) textproto.MIMEHeader {
headers := textproto.MIMEHeader{
headerContentType: []string{fmt.Sprintf(smtpFmtContentType, contentType)},
headerContentDisposition: []string{smtpFmtContentDispositionInline},
}
characteristics := NewMIMECharacteristics(data)
if !ext8BITMIME || characteristics.LongLines || characteristics.Characters8BIT {
headers.Set(headerContentTransferEncoding, smtpEncodingQuotedPrintable)
} else {
headers.Set(headerContentTransferEncoding, smtpEncoding8bit)
}
return headers
}
func multipartWrite(mwr *multipart.Writer, header textproto.MIMEHeader, data []byte) (err error) {
var (
wc io.WriteCloser
wr io.Writer
)
if wr, err = mwr.CreatePart(header); err != nil {
return err
}
switch header.Get(headerContentTransferEncoding) {
case smtpEncodingQuotedPrintable:
wc = quotedprintable.NewWriter(wr)
case smtpEncoding8bit, "":
wc = utils.NewWriteCloser(wr)
default:
return fmt.Errorf("unknown encoding: %s", header.Get(headerContentTransferEncoding))
}
if _, err = wc.Write(data); err != nil {
_ = wc.Close()
return err
}
_ = wc.Close()
return nil
}
// NewMIMECharacteristics detects the SMTP MIMECharacteristics for the given data bytes.
func NewMIMECharacteristics(data []byte) MIMECharacteristics {
characteristics := MIMECharacteristics{}
cl := 0
n := len(data)
for i := 0; i < n; i++ {
cl++
if cl > 1000 {
characteristics.LongLines = true
}
if data[i] == 10 {
cl = 0
if i == 0 || data[i-1] != 13 {
characteristics.LineFeeds = true
}
}
if data[i] >= 128 {
characteristics.Characters8BIT = true
}
}
return characteristics
}
// MIMECharacteristics represents specific MIME related characteristics.
type MIMECharacteristics struct {
LongLines bool
LineFeeds bool
Characters8BIT bool
}

View File

@ -0,0 +1,16 @@
package templates
import (
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldNotHaveDeprecatedPlaceholders(t *testing.T) {
data, err := embedFS.ReadFile(path.Join("src", TemplateCategoryNotifications, TemplateNameEmailEnvelope))
require.NoError(t, err)
assert.False(t, tmplEnvelopeHasDeprecatedPlaceholders(data))
}

View File

@ -25,23 +25,33 @@ type Provider struct {
}
// ExecuteEmailEnvelope writes the envelope template to the given io.Writer.
func (p Provider) ExecuteEmailEnvelope(wr io.Writer, data EmailEnvelopeValues) (err error) {
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) {
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) {
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 tPath, embed, data, err := readTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil {
errs = append(errs, err)
} else {
if !embed && tmplEnvelopeHasDeprecatedPlaceholders(data) {
errs = append(errs, fmt.Errorf("the evelope template override appears to contain removed placeholders"))
} else if p.templates.notification.envelope, err = parseTemplate(TemplateNameEmailEnvelope, tPath, embed, data); err != nil {
errs = append(errs, err)
}
}
if p.templates.notification.envelope, err = loadTemplate(TemplateNameEmailEnvelope, TemplateCategoryNotifications, p.config.EmailTemplatesPath); err != nil {
errs = append(errs, err)
}

View File

@ -3,18 +3,3 @@ 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

@ -62,12 +62,4 @@ type EmailEnvelopeValues struct {
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

@ -1,6 +1,7 @@
package templates
import (
"bytes"
"fmt"
"os"
"path"
@ -8,6 +9,12 @@ import (
"text/template"
)
func tmplEnvelopeHasDeprecatedPlaceholders(data []byte) bool {
return bytes.Contains(data, []byte("{{ .Boundary }}")) ||
bytes.Contains(data, []byte("{{ .Body.PlainText }}")) ||
bytes.Contains(data, []byte("{{ .Body.HTML }}"))
}
func templateExists(path string) (exists bool) {
info, err := os.Stat(path)
if err != nil {
@ -21,28 +28,51 @@ func templateExists(path string) (exists bool) {
return true
}
//nolint:unparam
func loadTemplate(name, category, overridePath string) (t *template.Template, err error) {
func readTemplate(name, category, overridePath string) (tPath string, embed bool, data []byte, err error) {
if overridePath != "" {
tPath := filepath.Join(overridePath, name)
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)
if data, err = os.ReadFile(tPath); err != nil {
return tPath, false, nil, fmt.Errorf("failed to read template override at path '%s': %w", tPath, err)
}
return t, nil
return tPath, false, data, nil
}
}
data, err := embedFS.ReadFile(path.Join("src", category, name))
if err != nil {
return nil, err
tPath = path.Join("src", category, name)
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, data, nil
}
func parseTemplate(name, tPath string, embed bool, data []byte) (t *template.Template, err error) {
if t, err = template.New(name).Parse(string(data)); err != nil {
panic(fmt.Errorf("failed to parse internal template: %w", err))
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
}
//nolint:unparam
func loadTemplate(name, category, overridePath string) (t *template.Template, err error) {
var (
embed bool
tPath string
data []byte
)
if tPath, embed, data, err = readTemplate(name, category, overridePath); err != nil {
return nil, err
}
return parseTemplate(name, tPath, embed, data)
}

View File

@ -0,0 +1,34 @@
package utils
import (
"errors"
"io"
)
// NewWriteCloser creates a new io.WriteCloser from an io.Writer.
func NewWriteCloser(wr io.Writer) io.WriteCloser {
return &WriteCloser{wr: wr}
}
// WriteCloser is a io.Writer with an io.Closer.
type WriteCloser struct {
wr io.Writer
closed bool
}
// Write to the io.Writer.
func (w *WriteCloser) Write(p []byte) (n int, err error) {
if w.closed {
return -1, errors.New("already closed")
}
return w.wr.Write(p)
}
// Close the io.Closer.
func (w *WriteCloser) Close() error {
w.closed = true
return nil
}