diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 66aa8172..0f111bb9 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -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 { diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index 6e858eb2..87a1ce43 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -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 } diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go index 091f18f2..c8d0d1b0 100644 --- a/backend/app/notify/email_test.go +++ b/backend/app/notify/email_test.go @@ -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: -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: -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 -} diff --git a/site/src/docs/configuration/email/index.md b/site/src/docs/configuration/email/index.md index 102ca299..4e4a5a28 100644 --- a/site/src/docs/configuration/email/index.md +++ b/site/src/docs/configuration/email/index.md @@ -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 diff --git a/site/src/docs/configuration/parameters/index.md b/site/src/docs/configuration/parameters/index.md index 31bb11f3..1c3c61b7 100644 --- a/site/src/docs/configuration/parameters/index.md +++ b/site/src/docs/configuration/parameters/index.md @@ -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 |