switch to go-pkgz/notify package: email
This commit is contained in:
committed by
Umputun
parent
0d9c80aec7
commit
a980bdbad2
@@ -209,6 +209,7 @@ type SMTPGroup struct {
|
||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
||||
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
||||
}
|
||||
|
||||
@@ -1030,13 +1031,16 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
||||
if contains("email", s.Notify.Admins) {
|
||||
emailParams.AdminEmails = s.Admin.Shared.Email
|
||||
}
|
||||
smtpParams := notify.SMTPParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
TLS: s.SMTP.TLS,
|
||||
Username: s.SMTP.Username,
|
||||
Password: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
smtpParams := ntf.SMTPParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
TLS: s.SMTP.TLS,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
Username: s.SMTP.Username,
|
||||
Password: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
ContentType: "text/html",
|
||||
Charset: "UTF-8",
|
||||
}
|
||||
emailService, err := notify.NewEmail(emailParams, smtpParams)
|
||||
if err != nil {
|
||||
|
||||
+48
-197
@@ -3,20 +3,15 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/templates"
|
||||
)
|
||||
@@ -34,50 +29,15 @@ type EmailParams struct {
|
||||
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
||||
}
|
||||
|
||||
// SMTPParams contain settings for smtp server connection
|
||||
type SMTPParams struct {
|
||||
Host string // SMTP host
|
||||
Port int // SMTP port
|
||||
TLS bool // TLS auth
|
||||
Username string // user name
|
||||
Password string // password
|
||||
TimeOut time.Duration // TCP connection timeout
|
||||
}
|
||||
|
||||
// Email implements notify.Destination for email
|
||||
type Email struct {
|
||||
EmailParams
|
||||
SMTPParams
|
||||
*ntf.Email
|
||||
|
||||
smtp smtpClientCreator
|
||||
EmailParams
|
||||
msgTmpl *template.Template // parsed request message template
|
||||
verifyTmpl *template.Template // parsed verification message template
|
||||
}
|
||||
|
||||
// default email client implementation
|
||||
type emailClient struct{ smtpClientCreator }
|
||||
|
||||
// smtpClient interface defines subset of net/smtp used by email client
|
||||
type smtpClient interface {
|
||||
Mail(string) error
|
||||
Auth(smtp.Auth) error
|
||||
Rcpt(string) error
|
||||
Data() (io.WriteCloser, error)
|
||||
Quit() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// smtpClientCreator interface defines function for creating new smtpClients
|
||||
type smtpClientCreator interface {
|
||||
Create(SMTPParams) (smtpClient, error)
|
||||
}
|
||||
|
||||
type emailMessage struct {
|
||||
from string
|
||||
to string
|
||||
message string
|
||||
}
|
||||
|
||||
// msgTmplData store data for message from request template execution
|
||||
type msgTmplData struct {
|
||||
UserName string
|
||||
@@ -113,15 +73,14 @@ const (
|
||||
)
|
||||
|
||||
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
||||
func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
|
||||
func NewEmail(emailParams EmailParams, smtpParams ntf.SMTPParams) (*Email, error) {
|
||||
// set up Email emailParams
|
||||
res := Email{EmailParams: emailParams}
|
||||
res.smtp = &emailClient{}
|
||||
res.SMTPParams = smtpParams
|
||||
if res.TimeOut <= 0 {
|
||||
res.TimeOut = defaultEmailTimeout
|
||||
if smtpParams.TimeOut <= 0 {
|
||||
smtpParams.TimeOut = defaultEmailTimeout
|
||||
}
|
||||
|
||||
res := Email{Email: ntf.NewEmail(smtpParams), EmailParams: emailParams}
|
||||
|
||||
if res.VerificationSubject == "" {
|
||||
res.VerificationSubject = defaultVerificationSubject
|
||||
}
|
||||
@@ -152,16 +111,16 @@ func (e *Email) setTemplates() error {
|
||||
}
|
||||
|
||||
if msgTmplFile, err = fs.ReadFile(e.MsgTemplatePath); err != nil {
|
||||
return errors.Wrapf(err, "can't read message template")
|
||||
return fmt.Errorf("can't read message template: %w", err)
|
||||
}
|
||||
if verifyTmplFile, err = fs.ReadFile(e.VerificationTemplatePath); err != nil {
|
||||
return errors.Wrapf(err, "can't read verification template")
|
||||
return fmt.Errorf("can't read verification template: %w", err)
|
||||
}
|
||||
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
|
||||
return errors.Wrapf(err, "can't parse message template")
|
||||
return fmt.Errorf("can't parse message template: %w", err)
|
||||
}
|
||||
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
|
||||
return errors.Wrapf(err, "can't parse verification template")
|
||||
return fmt.Errorf("can't parse verification template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -206,7 +165,16 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.sendMessage(emailMessage{from: e.From, to: email, message: msg})
|
||||
return e.Email.Send(
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
||||
email,
|
||||
e.From,
|
||||
url.QueryEscape(msg.unsubscribeLink),
|
||||
url.QueryEscape(msg.subject),
|
||||
),
|
||||
msg.body,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -232,13 +200,20 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
|
||||
return e.Email.Send(
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
||||
req.Email,
|
||||
e.From,
|
||||
url.QueryEscape(e.VerificationSubject),
|
||||
),
|
||||
msg,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// buildVerificationMessage generates verification email message based on given input
|
||||
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
||||
subject := e.VerificationSubject
|
||||
msg := bytes.Buffer{}
|
||||
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
||||
User: user,
|
||||
@@ -248,13 +223,19 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
|
||||
SubscribeURL: e.SubscribeURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build verification message")
|
||||
return "", fmt.Errorf("error executing template to build verification message: %w", err)
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), email, "text/html", "")
|
||||
return msg.String(), nil
|
||||
}
|
||||
|
||||
type commentMessage struct {
|
||||
subject string
|
||||
body string
|
||||
unsubscribeLink string
|
||||
}
|
||||
|
||||
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
||||
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (string, error) {
|
||||
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (commentMessage, error) {
|
||||
subject := "New reply to your comment"
|
||||
if forAdmin {
|
||||
subject = "New comment to your site"
|
||||
@@ -265,7 +246,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
||||
|
||||
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
|
||||
return commentMessage{}, fmt.Errorf("error creating token for unsubscribe link: %w", err)
|
||||
}
|
||||
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
|
||||
if forAdmin {
|
||||
@@ -295,141 +276,11 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
||||
}
|
||||
err = e.msgTmpl.Execute(&msg, tmplData)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build comment reply message")
|
||||
return commentMessage{}, fmt.Errorf("error executing template to build comment reply message: %w", err)
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), email, "text/html", unsubscribeLink)
|
||||
}
|
||||
|
||||
// buildMessage generates email message to send using net/smtp.Data()
|
||||
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
|
||||
addHeader := func(msg, h, v string) string {
|
||||
msg += fmt.Sprintf("%s: %s\n", h, v)
|
||||
return msg
|
||||
}
|
||||
message = addHeader(message, "From", e.From)
|
||||
message = addHeader(message, "To", to)
|
||||
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", subject))
|
||||
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
|
||||
|
||||
if contentType != "" {
|
||||
message = addHeader(message, "MIME-version", "1.0")
|
||||
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
|
||||
}
|
||||
|
||||
if unsubscribeLink != "" {
|
||||
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
|
||||
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
|
||||
}
|
||||
|
||||
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
|
||||
|
||||
buff := &bytes.Buffer{}
|
||||
qp := quotedprintable.NewWriter(buff)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// flush now, must NOT use defer, for small body, defer may cause buff.String() got empty body
|
||||
if err := qp.Close(); err != nil {
|
||||
return "", fmt.Errorf("quotedprintable Write failed: %w", err)
|
||||
}
|
||||
m := buff.String()
|
||||
message += "\n" + m
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
|
||||
// Thread safe.
|
||||
func (e *Email) sendMessage(m emailMessage) error {
|
||||
if e.smtp == nil {
|
||||
return fmt.Errorf("sendMessage called without client set")
|
||||
}
|
||||
client, err := e.smtp.Create(e.SMTPParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to make smtp Create: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = client.Quit(); err != nil {
|
||||
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
|
||||
if err = client.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp connection, %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err = client.Mail(m.from); err != nil {
|
||||
return fmt.Errorf("bad from address %q: %w", m.from, err)
|
||||
}
|
||||
if err = client.Rcpt(m.to); err != nil {
|
||||
return fmt.Errorf("bad to address %q: %w", m.to, err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't make email writer: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = writer.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp body writer, %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := bytes.NewBufferString(m.message)
|
||||
if _, err = buf.WriteTo(writer); err != nil {
|
||||
return fmt.Errorf("failed to send email body to %q: %w", m.to, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String representation of Email object
|
||||
func (e *Email) String() string {
|
||||
return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port)
|
||||
}
|
||||
|
||||
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SMTPParams
|
||||
// and returns pointer to it. Thread safe.
|
||||
func (s *emailClient) Create(params SMTPParams) (smtpClient, error) {
|
||||
authenticate := func(c *smtp.Client) error {
|
||||
if params.Username == "" || params.Password == "" {
|
||||
return nil
|
||||
}
|
||||
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to auth to smtp %s:%d: %w", params.Host, params.Port, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var c *smtp.Client
|
||||
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
|
||||
if params.TLS {
|
||||
tlsConf := &tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
ServerName: params.Host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to dial smtp tls to %s: %w", srvAddress, err)
|
||||
}
|
||||
if c, err = smtp.NewClient(conn, params.Host); err != nil {
|
||||
return nil, fmt.Errorf("failed to make smtp client for %s: %w", srvAddress, err)
|
||||
}
|
||||
return c, authenticate(c)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("timeout connecting to %s: %w", srvAddress, err)
|
||||
}
|
||||
|
||||
c, err = smtp.NewClient(conn, params.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
return c, authenticate(c)
|
||||
return commentMessage{
|
||||
subject: subject,
|
||||
body: msg.String(),
|
||||
unsubscribeLink: unsubscribeLink,
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"sync"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -23,13 +19,13 @@ func TestEmailNew(t *testing.T) {
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}
|
||||
smtpParams := SMTPParams{
|
||||
smtpParams := ntf.SMTPParams{
|
||||
Host: "test@host",
|
||||
Port: 1000,
|
||||
TLS: true,
|
||||
StartTLS: true,
|
||||
Username: "test@username",
|
||||
Password: "test@password",
|
||||
TimeOut: time.Second,
|
||||
}
|
||||
|
||||
email, err := NewEmail(emailParams, smtpParams)
|
||||
@@ -49,6 +45,8 @@ func TestEmailNew(t *testing.T) {
|
||||
assert.Equal(t, smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
|
||||
assert.Equal(t, smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
|
||||
assert.Equal(t, smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
|
||||
assert.Equal(t, smtpParams.StartTLS, email.StartTLS, "emailParams.TLS unchanged after creation")
|
||||
assert.Equal(t, "email: with username 'test@username' at server test@host:1000 with TLS", email.String())
|
||||
}
|
||||
|
||||
func Test_initTemplatesErr(t *testing.T) {
|
||||
@@ -58,19 +56,31 @@ func Test_initTemplatesErr(t *testing.T) {
|
||||
emailParams EmailParams
|
||||
}{
|
||||
{
|
||||
name: "with wrong path to verification template",
|
||||
errText: "can't read verification template: open notfount.tmpl: no such file or directory",
|
||||
name: "with wrong (default, working in prod) path to reply template",
|
||||
errText: "can't read message template: open email_reply.html.tmpl: no such file or directory",
|
||||
emailParams: EmailParams{},
|
||||
},
|
||||
{
|
||||
name: "with wrong (default, working in prod) path to verification template",
|
||||
errText: "can't read verification template: open email_confirmation_subscription.html.tmpl: no such file or directory",
|
||||
emailParams: EmailParams{
|
||||
VerificationTemplatePath: "notfount.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with wrong path to verification template",
|
||||
errText: "can't read verification template: open notfound.tmpl: no such file or directory",
|
||||
emailParams: EmailParams{
|
||||
VerificationTemplatePath: "notfound.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with wrong path to message template",
|
||||
errText: "can't read message template: open notfount.tmpl: no such file or directory",
|
||||
errText: "can't read message template: open notfound.tmpl: no such file or directory",
|
||||
emailParams: EmailParams{
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "notfount.tmpl",
|
||||
MsgTemplatePath: "notfound.tmpl",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -94,9 +104,9 @@ func Test_initTemplatesErr(t *testing.T) {
|
||||
for _, d := range testSet {
|
||||
d := d
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
e := Email{EmailParams: d.emailParams}
|
||||
err := e.setTemplates()
|
||||
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, e)
|
||||
assert.Contains(t, err.Error(), d.errText)
|
||||
})
|
||||
}
|
||||
@@ -125,7 +135,6 @@ func TestEmailSendErrors(t *testing.T) {
|
||||
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
|
||||
"sending email messages about comment \"999\" aborted due to canceled context")
|
||||
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
|
||||
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+
|
||||
" error creating token for unsubscribe link: token generation error\n\n")
|
||||
@@ -135,7 +144,7 @@ func TestEmailSend_ExitConditions(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, SMTPParams{})
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email, "expecting email returned")
|
||||
// prevent triggering e.autoFlush creation
|
||||
@@ -144,67 +153,14 @@ func TestEmailSend_ExitConditions(t *testing.T) {
|
||||
"Message without Emails and AdminEmails is not sent and returns nil")
|
||||
}
|
||||
|
||||
func TestEmailSendClientError(t *testing.T) {
|
||||
var testSet = []struct {
|
||||
name string
|
||||
smtp *fakeTestSMTP
|
||||
err string
|
||||
}{
|
||||
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
|
||||
err: "bad from address \"\": failed to verify sender"},
|
||||
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
|
||||
err: "bad to address \"\": failed to verify receiver"},
|
||||
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}},
|
||||
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
|
||||
err: "can't make email writer: failed to send"},
|
||||
}
|
||||
for _, d := range testSet {
|
||||
d := d
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
e := Email{smtp: d.smtp}
|
||||
if d.err != "" {
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), d.err,
|
||||
"expected error for e.sendMessage")
|
||||
} else {
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}),
|
||||
"expected no error for e.sendMessage")
|
||||
}
|
||||
})
|
||||
}
|
||||
e := Email{}
|
||||
e.smtp = nil
|
||||
assert.Error(t, e.sendMessage(emailMessage{}),
|
||||
"nil e.smtp should return error")
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage in normal flow")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client",
|
||||
"e.send called without smtpClient set returns error")
|
||||
}
|
||||
|
||||
func TestEmail_DefaultTemplates(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{}, SMTPParams{})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, email)
|
||||
email, err = NewEmail(EmailParams{VerificationTemplatePath: "testdata/verification.html.tmpl"}, SMTPParams{})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, email)
|
||||
}
|
||||
|
||||
func TestEmail_Send(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, SMTPParams{})
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email)
|
||||
fakeSMTP := fakeTestSMTP{}
|
||||
email.smtp = &fakeSMTP
|
||||
email.TokenGenFn = TokenGenFn
|
||||
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
|
||||
req := Request{
|
||||
@@ -212,22 +168,21 @@ func TestEmail_Send(t *testing.T) {
|
||||
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
assert.NoError(t, email.Send(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
|
||||
assert.Equal(t, 1, fakeSMTP.readQuitCount())
|
||||
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
|
||||
assert.Contains(t, email.Send(context.Background(), req).Error(), "problem sending user email notification to \"test@example.org\"")
|
||||
// test buildMessageFromRequest separately for message text
|
||||
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: New reply to your comment for "test_title"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
|
||||
Date: `)
|
||||
assert.Equal(t, `
|
||||
New reply from test_user on your comment to «test_title»
|
||||
|
||||
User: test_user
|
||||
01.01.0001 at 00:00
|
||||
Comment:
|
||||
test@example.org for parent_user
|
||||
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
||||
`, msg.body)
|
||||
assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink)
|
||||
assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject)
|
||||
|
||||
// send email to both user and admin, without parent set
|
||||
email.AdminEmails = []string{"admin@example.org"}
|
||||
@@ -235,51 +190,19 @@ Date: `)
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
assert.NoError(t, email.Send(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
|
||||
assert.Equal(t, 3, fakeSMTP.readQuitCount(), "plus two emails: one for user and one for admin")
|
||||
assert.Equal(t, "admin@example.org", fakeSMTP.readRcpt())
|
||||
res, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
|
||||
assert.Error(t, email.Send(context.Background(), req))
|
||||
msg, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: admin@example.org
|
||||
Subject: New comment to your site for "test_title"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Date: `)
|
||||
}
|
||||
assert.Equal(t, `
|
||||
New comment from test_user on your site to «test_title»
|
||||
|
||||
func TestEmail_SendWithUnicodeInSubject(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email)
|
||||
fakeSMTP := fakeTestSMTP{}
|
||||
email.smtp = &fakeSMTP
|
||||
email.TokenGenFn = TokenGenFn
|
||||
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
|
||||
req := Request{
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "Привет"},
|
||||
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
// test buildMessageFromRequest separately for message text
|
||||
res, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||
assert.NoError(t, err)
|
||||
// `=?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=` -> `New reply to your comment for "Привет"` in base64 + required prefix and suffix
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: =?utf-8?b?TmV3IHJlcGx5IHRvIHlvdXIgY29tbWVudCBmb3IgItCf0YDQuNCy0LXRgiI=?=
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
|
||||
Date: `)
|
||||
User: test_user
|
||||
01.01.0001 at 00:00
|
||||
Comment:
|
||||
admin@example.org
|
||||
`, msg.body)
|
||||
assert.Equal(t, `New comment to your site for "test_title"`, msg.subject)
|
||||
assert.Empty(t, msg.unsubscribeLink)
|
||||
}
|
||||
|
||||
func TestEmail_SendVerification(t *testing.T) {
|
||||
@@ -287,11 +210,9 @@ func TestEmail_SendVerification(t *testing.T) {
|
||||
From: "from@example.org",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, SMTPParams{})
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email)
|
||||
fakeSMTP := fakeTestSMTP{}
|
||||
email.smtp = &fakeSMTP
|
||||
email.TokenGenFn = TokenGenFn
|
||||
// proper VerificationRequest without email
|
||||
req := VerificationRequest{
|
||||
@@ -299,136 +220,36 @@ func TestEmail_SendVerification(t *testing.T) {
|
||||
User: "test_username",
|
||||
Token: "secret_",
|
||||
}
|
||||
assert.NoError(t, email.SendVerification(context.TODO(), req))
|
||||
assert.Equal(t, "", fakeSMTP.readMail())
|
||||
assert.Equal(t, 0, fakeSMTP.readQuitCount())
|
||||
assert.Equal(t, "", fakeSMTP.readRcpt())
|
||||
assert.NoError(t, email.SendVerification(context.Background(), req))
|
||||
|
||||
// proper VerificationRequest with email
|
||||
req.Email = "test@example.org"
|
||||
assert.NoError(t, email.SendVerification(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSMTP.readMail())
|
||||
assert.Equal(t, 1, fakeSMTP.readQuitCount())
|
||||
assert.Equal(t, "test@example.org", fakeSMTP.readRcpt())
|
||||
assert.Error(t, email.SendVerification(context.Background(), req), "failed to make smtp client")
|
||||
|
||||
// VerificationRequest with canceled context
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
assert.EqualError(t, email.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context")
|
||||
|
||||
// test buildVerificationMessage separately for message text
|
||||
res, err := email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: Email verification
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Date: `)
|
||||
assert.Equal(t, res, `Confirmation for test_username on site remark
|
||||
Token:secret_
|
||||
Sent to test@example.org
|
||||
|
||||
`)
|
||||
assert.Contains(t, res, `secret_`)
|
||||
assert.NotContains(t, res, `https://example.org/`)
|
||||
email.SubscribeURL = "https://example.org/subscribe.html?token="
|
||||
res, err = email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: Email verification
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Date: `)
|
||||
assert.Contains(t, res, `https://example.org/subscribe.html?token=3Dsecret_`)
|
||||
}
|
||||
assert.Equal(t, res, `Confirmation for test_username on site remark
|
||||
Subscribe url: https://example.org/subscribe.html?token=secret_
|
||||
Token:secret_
|
||||
Sent to test@example.org
|
||||
|
||||
func Test_emailClient_Create(t *testing.T) {
|
||||
creator := emailClient{}
|
||||
client, err := creator.Create(SMTPParams{})
|
||||
assert.Error(t, err, "absence of address to connect results in error")
|
||||
assert.Nil(t, client, "no client returned in case of error")
|
||||
}
|
||||
|
||||
type fakeTestSMTP struct {
|
||||
fail map[string]bool
|
||||
|
||||
buff bytes.Buffer
|
||||
mail, rcpt string
|
||||
auth bool
|
||||
close bool
|
||||
quitCount int
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Create(SMTPParams) (smtpClient, error) {
|
||||
if f.fail["create"] {
|
||||
return nil, fmt.Errorf("failed to create client")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
|
||||
|
||||
func (f *fakeTestSMTP) Mail(m string) error {
|
||||
f.lock.Lock()
|
||||
f.mail = m
|
||||
f.lock.Unlock()
|
||||
if f.fail["mail"] {
|
||||
return fmt.Errorf("failed to verify sender")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Rcpt(r string) error {
|
||||
f.lock.Lock()
|
||||
f.rcpt = r
|
||||
f.lock.Unlock()
|
||||
if f.fail["rcpt"] {
|
||||
return fmt.Errorf("failed to verify receiver")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Quit() error {
|
||||
f.lock.Lock()
|
||||
f.quitCount++
|
||||
f.lock.Unlock()
|
||||
if f.fail["quit"] {
|
||||
return fmt.Errorf("failed to quit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Close() error {
|
||||
f.close = true
|
||||
if f.fail["close"] {
|
||||
return fmt.Errorf("failed to close")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
|
||||
if f.fail["data"] {
|
||||
return nil, fmt.Errorf("failed to send")
|
||||
}
|
||||
return nopCloser{&f.buff}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readRcpt() string {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.rcpt
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readMail() string {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.mail
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readQuitCount() int {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.quitCount
|
||||
`)
|
||||
}
|
||||
|
||||
func TokenGenFn(user, _, _ string) (string, error) {
|
||||
@@ -437,11 +258,3 @@ func TokenGenFn(user, _, _ string) (string, error) {
|
||||
}
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
type nopCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (nopCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ To enable any email functionality, you need to set up an email (SMTP) server con
|
||||
SMTP_HOST
|
||||
SMTP_PORT
|
||||
SMTP_TLS
|
||||
SMTP_STARTTLS
|
||||
SMTP_USERNAME
|
||||
SMTP_PASSWORD
|
||||
SMTP_TIMEOUT
|
||||
|
||||
@@ -106,6 +106,7 @@ services:
|
||||
| smtp.username | SMTP_USERNAME | | SMTP user name |
|
||||
| smtp.password | SMTP_PASSWORD | | SMTP password |
|
||||
| smtp.tls | SMTP_TLS | `false` | enable TLS for SMTP |
|
||||
| smtp.starttls | SMTP_STARTTLS | `false` | enable StartTLS for SMTP |
|
||||
| smtp.timeout | SMTP_TIMEOUT | `10s` | SMTP TCP connection timeout |
|
||||
| ssl.type | SSL_TYPE | none | `none`-HTTP, `static`-HTTPS, `auto`-HTTPS + le |
|
||||
| ssl.port | SSL_PORT | `8443` | port for HTTPS server |
|
||||
|
||||
Reference in New Issue
Block a user