Skip to content

Commit f727b7f

Browse files
committed
WIP: SMTP tests
Signed-off-by: Danny Kopping <danny@coder.com>
1 parent aaf295b commit f727b7f

File tree

6 files changed

+528
-48
lines changed

6 files changed

+528
-48
lines changed

coderd/notifications/dispatch/smtp.go

Lines changed: 95 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import (
99
"mime/quotedprintable"
1010
"net"
1111
"net/mail"
12-
"net/smtp"
1312
"net/textproto"
1413
"os"
14+
"slices"
1515
"strings"
1616
"time"
1717

18+
"github.com/emersion/go-sasl"
19+
smtp "github.com/emersion/go-smtp"
1820
"github.com/google/uuid"
21+
"github.com/hashicorp/go-multierror"
1922
"golang.org/x/xerrors"
2023

2124
"cdr.dev/slog"
@@ -41,8 +44,8 @@ var (
4144

4245
// SMTPHandler is responsible for dispatching notification messages via SMTP.
4346
// NOTE: auth and TLS is currently *not* enabled in this initial thin slice.
44-
// TODO: implement auth
4547
// TODO: implement TLS
48+
// TODO: implement DKIM/SPF/DMARC: https://github.com/emersion/go-msgauth
4649
type SMTPHandler struct {
4750
cfg codersdk.NotificationsEmailConfig
4851
log slog.Logger
@@ -83,7 +86,11 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
8386

8487
// dispatch returns a DeliveryFunc capable of delivering a notification via SMTP.
8588
//
86-
// NOTE: this is heavily inspired by Alertmanager's email notifier:
89+
// Our requirements are too complex to be implemented using smtp.SendMail:
90+
// - we require custom TLS settings
91+
// - dynamic determination of available AUTH mechanisms
92+
//
93+
// NOTE: this is inspired by Alertmanager's email notifier:
8794
// https://github.com/prometheus/alertmanager/blob/342f6a599ce16c138663f18ed0b880e777c3017d/notify/email/email.go
8895
func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) DeliveryFunc {
8996
return func(ctx context.Context, msgID uuid.UUID) (bool, error) {
@@ -110,21 +117,15 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
110117
return false, xerrors.New("TLS is not currently supported")
111118
}
112119

113-
var d net.Dialer
114120
// Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT).
121+
var d net.Dialer
115122
conn, err = d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%s", smarthost, smarthostPort))
116123
if err != nil {
117124
return true, xerrors.Errorf("establish connection to server: %w", err)
118125
}
119126

120127
// Create an SMTP client.
121-
c, err = smtp.NewClient(conn, smarthost)
122-
if err != nil {
123-
if cerr := conn.Close(); cerr != nil {
124-
s.log.Warn(ctx, "failed to close connection", slog.Error(cerr))
125-
}
126-
return true, xerrors.Errorf("create client: %w", err)
127-
}
128+
c = smtp.NewClient(conn)
128129

129130
// Cleanup.
130131
defer func() {
@@ -144,36 +145,39 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
144145
}
145146

146147
// Check for authentication capabilities.
147-
// if ok, mech := c.Extension("AUTH"); ok {
148-
// auth, err := s.auth(mech)
149-
// if err != nil {
150-
// return true, xerrors.Errorf("find auth mechanism: %w", err)
151-
// }
152-
// if auth != nil {
153-
// if err := c.Auth(auth); err != nil {
154-
// return true, xerrors.Errorf("%T auth: %w", auth, err)
155-
// }
156-
// }
157-
//}
148+
// TODO: prefer anything else over LOGIN, so build all auth providers then pick best one
149+
if ok, mech := c.Extension("AUTH"); ok {
150+
auth, err := s.auth(ctx, mech)
151+
if err != nil {
152+
return true, xerrors.Errorf("determine auth mechanism: %w", err)
153+
}
154+
if auth != nil {
155+
if err := c.Auth(auth); err != nil {
156+
return true, xerrors.Errorf("%T auth: %w", auth, err)
157+
}
158+
}
159+
} else if !s.cfg.Auth.Empty() {
160+
return false, xerrors.New("no authentication mechanisms supported by server")
161+
}
158162

159163
// Sender identification.
160164
from, err := s.validateFromAddr(s.cfg.From.String())
161165
if err != nil {
162166
return false, xerrors.Errorf("'from' validation: %w", err)
163167
}
164-
err = c.Mail(from)
168+
err = c.Mail(from, &smtp.MailOptions{})
165169
if err != nil {
166170
// This is retryable because the server may be temporarily down.
167171
return true, xerrors.Errorf("sender identification: %w", err)
168172
}
169173

170174
// Recipient designation.
171-
to, err := s.validateToAddrs(to)
175+
recipients, err := s.validateToAddrs(to)
172176
if err != nil {
173177
return false, xerrors.Errorf("'to' validation: %w", err)
174178
}
175-
for _, addr := range to {
176-
err = c.Rcpt(addr)
179+
for _, addr := range recipients {
180+
err = c.Rcpt(addr, &smtp.RcptOptions{})
177181
if err != nil {
178182
// This is a retryable case because the server may be temporarily down.
179183
// The addresses are already validated, although it is possible that the server might disagree - in which case
@@ -189,12 +193,12 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
189193
}
190194
defer message.Close()
191195

192-
// Transmit message headers.
196+
// Create message headers.
193197
msg := &bytes.Buffer{}
194198
multipartBuffer := &bytes.Buffer{}
195199
multipartWriter := multipart.NewWriter(multipartBuffer)
196200
_, _ = fmt.Fprintf(msg, "From: %s\r\n", from)
197-
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(to, ", "))
201+
_, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", "))
198202
_, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject)
199203
_, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname())
200204
_, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
@@ -260,10 +264,56 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
260264
}
261265
}
262266

263-
// auth returns a value which implements the smtp.Auth based on the available auth mechanism.
264-
// func (*SMTPHandler) auth(_ string) (smtp.Auth, error) {
265-
// return nil, nil
266-
//}
267+
// auth returns a value which implements the smtp.Auth based on the available auth mechanisms.
268+
func (s *SMTPHandler) auth(ctx context.Context, mechs string) (sasl.Client, error) {
269+
username := s.cfg.Auth.Username.String()
270+
if username == "" {
271+
s.log.Warn(ctx, "username not configured; not using authentication")
272+
return nil, nil
273+
}
274+
275+
var errs error
276+
list := strings.Split(mechs, " ")
277+
for _, mech := range list {
278+
switch mech {
279+
case sasl.Plain:
280+
password, err := s.password()
281+
if err != nil {
282+
errs = multierror.Append(errs, err)
283+
continue
284+
}
285+
if password == "" {
286+
errs = multierror.Append(errs, xerrors.New("cannot use PLAIN auth, password not defined")) // TODO: show env/flag here
287+
continue
288+
}
289+
290+
return sasl.NewPlainClient(s.cfg.Auth.Identity.String(), username, password), nil
291+
case sasl.Login:
292+
if slices.Contains(list, sasl.Plain) {
293+
// Prefer PLAIN over LOGIN.
294+
continue
295+
}
296+
297+
s.log.Warn(ctx, "LOGIN auth is obsolete and should be avoided (use PLAIN instead): https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt")
298+
299+
password, err := s.password()
300+
if err != nil {
301+
errs = multierror.Append(errs, err)
302+
continue
303+
}
304+
if password == "" {
305+
errs = multierror.Append(errs, xerrors.New("cannot use LOGIN auth, password not defined")) // TODO: show env/flag here
306+
continue
307+
}
308+
309+
return sasl.NewLoginClient(username, password), nil
310+
default:
311+
return nil, xerrors.Errorf("unsupported auth mechanism: %q (supported: %v)", mechs, []string{sasl.Plain, sasl.Login})
312+
}
313+
}
314+
315+
return nil, errs
316+
}
267317

268318
func (*SMTPHandler) validateFromAddr(from string) (string, error) {
269319
addrs, err := mail.ParseAddressList(from)
@@ -330,3 +380,16 @@ func (*SMTPHandler) hostname() string {
330380
}
331381
return h
332382
}
383+
384+
// password returns either the configured password, or reads it from the configured file (if possible).
385+
func (s *SMTPHandler) password() (string, error) {
386+
file := s.cfg.Auth.PasswordFile.String()
387+
if len(file) > 0 {
388+
content, err := os.ReadFile(file)
389+
if err != nil {
390+
return "", xerrors.Errorf("could not read %s: %w", file, err)
391+
}
392+
return string(content), nil
393+
}
394+
return s.cfg.Auth.Password.String(), nil
395+
}

0 commit comments

Comments
 (0)