@@ -9,13 +9,16 @@ import (
9
9
"mime/quotedprintable"
10
10
"net"
11
11
"net/mail"
12
- "net/smtp"
13
12
"net/textproto"
14
13
"os"
14
+ "slices"
15
15
"strings"
16
16
"time"
17
17
18
+ "github.com/emersion/go-sasl"
19
+ smtp "github.com/emersion/go-smtp"
18
20
"github.com/google/uuid"
21
+ "github.com/hashicorp/go-multierror"
19
22
"golang.org/x/xerrors"
20
23
21
24
"cdr.dev/slog"
41
44
42
45
// SMTPHandler is responsible for dispatching notification messages via SMTP.
43
46
// NOTE: auth and TLS is currently *not* enabled in this initial thin slice.
44
- // TODO: implement auth
45
47
// TODO: implement TLS
48
+ // TODO: implement DKIM/SPF/DMARC: https://github.com/emersion/go-msgauth
46
49
type SMTPHandler struct {
47
50
cfg codersdk.NotificationsEmailConfig
48
51
log slog.Logger
@@ -83,7 +86,11 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm
83
86
84
87
// dispatch returns a DeliveryFunc capable of delivering a notification via SMTP.
85
88
//
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:
87
94
// https://github.com/prometheus/alertmanager/blob/342f6a599ce16c138663f18ed0b880e777c3017d/notify/email/email.go
88
95
func (s * SMTPHandler ) dispatch (subject , htmlBody , plainBody , to string ) DeliveryFunc {
89
96
return func (ctx context.Context , msgID uuid.UUID ) (bool , error ) {
@@ -110,21 +117,15 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
110
117
return false , xerrors .New ("TLS is not currently supported" )
111
118
}
112
119
113
- var d net.Dialer
114
120
// Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT).
121
+ var d net.Dialer
115
122
conn , err = d .DialContext (ctx , "tcp" , fmt .Sprintf ("%s:%s" , smarthost , smarthostPort ))
116
123
if err != nil {
117
124
return true , xerrors .Errorf ("establish connection to server: %w" , err )
118
125
}
119
126
120
127
// 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 )
128
129
129
130
// Cleanup.
130
131
defer func () {
@@ -144,36 +145,39 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery
144
145
}
145
146
146
147
// 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
+ }
158
162
159
163
// Sender identification.
160
164
from , err := s .validateFromAddr (s .cfg .From .String ())
161
165
if err != nil {
162
166
return false , xerrors .Errorf ("'from' validation: %w" , err )
163
167
}
164
- err = c .Mail (from )
168
+ err = c .Mail (from , & smtp. MailOptions {} )
165
169
if err != nil {
166
170
// This is retryable because the server may be temporarily down.
167
171
return true , xerrors .Errorf ("sender identification: %w" , err )
168
172
}
169
173
170
174
// Recipient designation.
171
- to , err := s .validateToAddrs (to )
175
+ recipients , err := s .validateToAddrs (to )
172
176
if err != nil {
173
177
return false , xerrors .Errorf ("'to' validation: %w" , err )
174
178
}
175
- for _ , addr := range to {
176
- err = c .Rcpt (addr )
179
+ for _ , addr := range recipients {
180
+ err = c .Rcpt (addr , & smtp. RcptOptions {} )
177
181
if err != nil {
178
182
// This is a retryable case because the server may be temporarily down.
179
183
// 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
189
193
}
190
194
defer message .Close ()
191
195
192
- // Transmit message headers.
196
+ // Create message headers.
193
197
msg := & bytes.Buffer {}
194
198
multipartBuffer := & bytes.Buffer {}
195
199
multipartWriter := multipart .NewWriter (multipartBuffer )
196
200
_ , _ = 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 , ", " ))
198
202
_ , _ = fmt .Fprintf (msg , "Subject: %s\r \n " , subject )
199
203
_ , _ = fmt .Fprintf (msg , "Message-Id: %s@%s\r \n " , msgID , s .hostname ())
200
204
_ , _ = 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
260
264
}
261
265
}
262
266
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
+ }
267
317
268
318
func (* SMTPHandler ) validateFromAddr (from string ) (string , error ) {
269
319
addrs , err := mail .ParseAddressList (from )
@@ -330,3 +380,16 @@ func (*SMTPHandler) hostname() string {
330
380
}
331
381
return h
332
382
}
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