1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 package email
30
31 import (
32 "context"
33 "fmt"
34 "io"
35 "net/http"
36 "net/url"
37 "os"
38 "strings"
39 "testing"
40 "time"
41
42 "github.com/go-kit/log"
43 commoncfg "github.com/prometheus/common/config"
44 "github.com/prometheus/common/model"
45 "github.com/stretchr/testify/require"
46 "gopkg.in/yaml.v2"
47
48 "github.com/prometheus/alertmanager/config"
49 "github.com/prometheus/alertmanager/template"
50 "github.com/prometheus/alertmanager/types"
51 )
52
53 const (
54 emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG"
55 emailAuthConfigVar = "EMAIL_AUTH_CONFIG"
56
57 emailTo = "alerts@example.com"
58 emailFrom = "alertmanager@example.com"
59 )
60
61
62
63 type email struct {
64 To []map[string]string
65 From []map[string]string
66 Subject string
67 HTML *string
68 Text *string
69 Headers map[string]string
70 }
71
72
73 type mailDev struct {
74 *url.URL
75 }
76
77 func (m *mailDev) UnmarshalYAML(unmarshal func(interface{}) error) error {
78 var s string
79 if err := unmarshal(&s); err != nil {
80 return err
81 }
82 urlp, err := url.Parse(s)
83 if err != nil {
84 return err
85 }
86 m.URL = urlp
87 return nil
88 }
89
90
91 func (m *mailDev) getLastEmail() (*email, error) {
92 code, b, err := m.doEmailRequest(http.MethodGet, "/email")
93 if err != nil {
94 return nil, err
95 }
96 if code != http.StatusOK {
97 return nil, fmt.Errorf("expected status OK, got %d", code)
98 }
99
100 var emails []email
101 err = yaml.Unmarshal(b, &emails)
102 if err != nil {
103 return nil, err
104 }
105 if len(emails) == 0 {
106 return nil, nil
107 }
108 return &emails[len(emails)-1], nil
109 }
110
111
112 func (m *mailDev) deleteAllEmails() error {
113 _, _, err := m.doEmailRequest(http.MethodDelete, "/email/all")
114 return err
115 }
116
117
118 func (m *mailDev) doEmailRequest(method, path string) (int, []byte, error) {
119 req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil)
120 if err != nil {
121 return 0, nil, err
122 }
123 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
124 req = req.WithContext(ctx)
125 defer cancel()
126 res, err := http.DefaultClient.Do(req)
127 if err != nil {
128 return 0, nil, err
129 }
130 defer res.Body.Close()
131 b, err := io.ReadAll(res.Body)
132 if err != nil {
133 return 0, nil, err
134 }
135 return res.StatusCode, b, nil
136 }
137
138
139 type emailTestConfig struct {
140 Smarthost config.HostPort `yaml:"smarthost"`
141 Username string `yaml:"username"`
142 Password string `yaml:"password"`
143 Server *mailDev `yaml:"server"`
144 }
145
146 func loadEmailTestConfiguration(f string) (emailTestConfig, error) {
147 c := emailTestConfig{}
148 b, err := os.ReadFile(f)
149 if err != nil {
150 return c, err
151 }
152
153 err = yaml.UnmarshalStrict(b, &c)
154 if err != nil {
155 return c, err
156 }
157
158 return c, nil
159 }
160
161 func notifyEmail(cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
162 return notifyEmailWithContext(context.Background(), cfg, server)
163 }
164
165
166
167 func notifyEmailWithContext(ctx context.Context, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
168 if cfg.RequireTLS == nil {
169 cfg.RequireTLS = new(bool)
170 }
171 if cfg.Headers == nil {
172 cfg.Headers = make(map[string]string)
173 }
174 firingAlert := &types.Alert{
175 Alert: model.Alert{
176 Labels: model.LabelSet{},
177 StartsAt: time.Now(),
178 EndsAt: time.Now().Add(time.Hour),
179 },
180 }
181 err := server.deleteAllEmails()
182 if err != nil {
183 return nil, false, err
184 }
185
186 tmpl, err := template.FromGlobs()
187 if err != nil {
188 return nil, false, err
189 }
190 tmpl.ExternalURL, _ = url.Parse("http://am")
191 email := New(cfg, tmpl, log.NewNopLogger())
192
193 retry, err := email.Notify(ctx, firingAlert)
194 if err != nil {
195 return nil, retry, err
196 }
197
198 e, err := server.getLastEmail()
199 if err != nil {
200 return nil, retry, err
201 } else if e == nil {
202 return nil, retry, fmt.Errorf("email not found")
203 }
204 return e, retry, nil
205 }
206
207
208 func TestEmailNotifyWithErrors(t *testing.T) {
209 cfgFile := os.Getenv(emailNoAuthConfigVar)
210 if len(cfgFile) == 0 {
211 t.Skipf("%s not set", emailNoAuthConfigVar)
212 }
213
214 c, err := loadEmailTestConfiguration(cfgFile)
215 if err != nil {
216 t.Fatal(err)
217 }
218
219 for _, tc := range []struct {
220 title string
221 updateCfg func(*config.EmailConfig)
222
223 errMsg string
224 hasEmail bool
225 }{
226 {
227 title: "invalid 'from' template",
228 updateCfg: func(cfg *config.EmailConfig) {
229 cfg.From = `{{ template "invalid" }}`
230 },
231 errMsg: "execute 'from' template:",
232 },
233 {
234 title: "invalid 'from' address",
235 updateCfg: func(cfg *config.EmailConfig) {
236 cfg.From = `xxx`
237 },
238 errMsg: "parse 'from' addresses:",
239 },
240 {
241 title: "invalid 'to' template",
242 updateCfg: func(cfg *config.EmailConfig) {
243 cfg.To = `{{ template "invalid" }}`
244 },
245 errMsg: "execute 'to' template:",
246 },
247 {
248 title: "invalid 'to' address",
249 updateCfg: func(cfg *config.EmailConfig) {
250 cfg.To = `xxx`
251 },
252 errMsg: "parse 'to' addresses:",
253 },
254 {
255 title: "invalid 'subject' template",
256 updateCfg: func(cfg *config.EmailConfig) {
257 cfg.Headers["subject"] = `{{ template "invalid" }}`
258 },
259 errMsg: `execute "subject" header template:`,
260 hasEmail: true,
261 },
262 {
263 title: "invalid 'text' template",
264 updateCfg: func(cfg *config.EmailConfig) {
265 cfg.Text = `{{ template "invalid" }}`
266 },
267 errMsg: `execute text template:`,
268 hasEmail: true,
269 },
270 {
271 title: "invalid 'html' template",
272 updateCfg: func(cfg *config.EmailConfig) {
273 cfg.HTML = `{{ template "invalid" }}`
274 },
275 errMsg: `execute html template:`,
276 hasEmail: true,
277 },
278 } {
279 tc := tc
280 t.Run(tc.title, func(t *testing.T) {
281 if len(tc.errMsg) == 0 {
282 t.Fatal("please define the expected error message")
283 return
284 }
285
286 emailCfg := &config.EmailConfig{
287 Smarthost: c.Smarthost,
288 To: emailTo,
289 From: emailFrom,
290 HTML: "HTML body",
291 Text: "Text body",
292 Headers: map[string]string{
293 "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
294 },
295 }
296 if tc.updateCfg != nil {
297 tc.updateCfg(emailCfg)
298 }
299
300 _, retry, err := notifyEmail(emailCfg, c.Server)
301 require.Error(t, err)
302 require.Contains(t, err.Error(), tc.errMsg)
303 require.Equal(t, false, retry)
304
305 e, err := c.Server.getLastEmail()
306 require.NoError(t, err)
307 if tc.hasEmail {
308 require.NotNil(t, e)
309 } else {
310 require.Nil(t, e)
311 }
312 })
313 }
314 }
315
316
317 func TestEmailNotifyWithDoneContext(t *testing.T) {
318 cfgFile := os.Getenv(emailNoAuthConfigVar)
319 if len(cfgFile) == 0 {
320 t.Skipf("%s not set", emailNoAuthConfigVar)
321 }
322
323 c, err := loadEmailTestConfiguration(cfgFile)
324 if err != nil {
325 t.Fatal(err)
326 }
327
328 ctx, cancel := context.WithCancel(context.Background())
329 cancel()
330 _, _, err = notifyEmailWithContext(
331 ctx,
332 &config.EmailConfig{
333 Smarthost: c.Smarthost,
334 To: emailTo,
335 From: emailFrom,
336 HTML: "HTML body",
337 Text: "Text body",
338 },
339 c.Server,
340 )
341 require.Error(t, err)
342 require.Contains(t, err.Error(), "establish connection to server")
343 }
344
345
346
347
348 func TestEmailNotifyWithoutAuthentication(t *testing.T) {
349 cfgFile := os.Getenv(emailNoAuthConfigVar)
350 if len(cfgFile) == 0 {
351 t.Skipf("%s not set", emailNoAuthConfigVar)
352 }
353
354 c, err := loadEmailTestConfiguration(cfgFile)
355 if err != nil {
356 t.Fatal(err)
357 }
358
359 mail, _, err := notifyEmail(
360 &config.EmailConfig{
361 Smarthost: c.Smarthost,
362 To: emailTo,
363 From: emailFrom,
364 HTML: "HTML body",
365 Text: "Text body",
366 },
367 c.Server,
368 )
369 require.NoError(t, err)
370 var (
371 foundMsgID bool
372 headers []string
373 )
374 for k := range mail.Headers {
375 if strings.ToLower(k) == "message-id" {
376 foundMsgID = true
377 break
378 }
379 headers = append(headers, k)
380 }
381 require.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
382 }
383
384
385
386
387
388
389 func TestEmailNotifyWithSTARTTLS(t *testing.T) {
390 cfgFile := os.Getenv(emailNoAuthConfigVar)
391 if len(cfgFile) == 0 {
392 t.Skipf("%s not set", emailNoAuthConfigVar)
393 }
394
395 c, err := loadEmailTestConfiguration(cfgFile)
396 if err != nil {
397 t.Fatal(err)
398 }
399
400 trueVar := true
401 _, _, err = notifyEmail(
402 &config.EmailConfig{
403 Smarthost: c.Smarthost,
404 To: emailTo,
405 From: emailFrom,
406 HTML: "HTML body",
407 Text: "Text body",
408 RequireTLS: &trueVar,
409
410 TLSConfig: commoncfg.TLSConfig{InsecureSkipVerify: true},
411 },
412 c.Server,
413 )
414 require.NoError(t, err)
415 }
416
417
418
419 func TestEmailNotifyWithAuthentication(t *testing.T) {
420 cfgFile := os.Getenv(emailAuthConfigVar)
421 if len(cfgFile) == 0 {
422 t.Skipf("%s not set", emailAuthConfigVar)
423 }
424
425 c, err := loadEmailTestConfiguration(cfgFile)
426 if err != nil {
427 t.Fatal(err)
428 }
429
430 fileWithCorrectPassword, err := os.CreateTemp("", "smtp-password-correct")
431 require.NoError(t, err, "creating temp file failed")
432 _, err = fileWithCorrectPassword.WriteString(c.Password)
433 require.NoError(t, err, "writing to temp file failed")
434
435 fileWithIncorrectPassword, err := os.CreateTemp("", "smtp-password-incorrect")
436 require.NoError(t, err, "creating temp file failed")
437 _, err = fileWithIncorrectPassword.WriteString(c.Password + "wrong")
438 require.NoError(t, err, "writing to temp file failed")
439
440 for _, tc := range []struct {
441 title string
442 updateCfg func(*config.EmailConfig)
443
444 errMsg string
445 retry bool
446 }{
447 {
448 title: "email with authentication",
449 updateCfg: func(cfg *config.EmailConfig) {
450 cfg.AuthUsername = c.Username
451 cfg.AuthPassword = config.Secret(c.Password)
452 },
453 },
454 {
455 title: "email with authentication (password from file)",
456 updateCfg: func(cfg *config.EmailConfig) {
457 cfg.AuthUsername = c.Username
458 cfg.AuthPasswordFile = fileWithCorrectPassword.Name()
459 },
460 },
461 {
462 title: "HTML-only email",
463 updateCfg: func(cfg *config.EmailConfig) {
464 cfg.AuthUsername = c.Username
465 cfg.AuthPassword = config.Secret(c.Password)
466 cfg.Text = ""
467 },
468 },
469 {
470 title: "text-only email",
471 updateCfg: func(cfg *config.EmailConfig) {
472 cfg.AuthUsername = c.Username
473 cfg.AuthPassword = config.Secret(c.Password)
474 cfg.HTML = ""
475 },
476 },
477 {
478 title: "multiple To addresses",
479 updateCfg: func(cfg *config.EmailConfig) {
480 cfg.AuthUsername = c.Username
481 cfg.AuthPassword = config.Secret(c.Password)
482 cfg.To = strings.Join([]string{emailTo, emailFrom}, ",")
483 },
484 },
485 {
486 title: "no more than one From address",
487 updateCfg: func(cfg *config.EmailConfig) {
488 cfg.AuthUsername = c.Username
489 cfg.AuthPassword = config.Secret(c.Password)
490 cfg.From = strings.Join([]string{emailFrom, emailTo}, ",")
491 },
492
493 errMsg: "must be exactly one 'from' address",
494 retry: false,
495 },
496 {
497 title: "wrong credentials",
498 updateCfg: func(cfg *config.EmailConfig) {
499 cfg.AuthUsername = c.Username
500 cfg.AuthPassword = config.Secret(c.Password + "wrong")
501 },
502
503 errMsg: "Invalid username or password",
504 retry: true,
505 },
506 {
507 title: "wrong credentials (password from file)",
508 updateCfg: func(cfg *config.EmailConfig) {
509 cfg.AuthUsername = c.Username
510 cfg.AuthPasswordFile = fileWithIncorrectPassword.Name()
511 },
512
513 errMsg: "Invalid username or password",
514 retry: true,
515 },
516 {
517 title: "wrong credentials (missing password file)",
518 updateCfg: func(cfg *config.EmailConfig) {
519 cfg.AuthUsername = c.Username
520 cfg.AuthPasswordFile = "/does/not/exist"
521 },
522
523 errMsg: "could not read",
524 retry: true,
525 },
526 {
527 title: "no credentials",
528 errMsg: "authentication Required",
529 retry: true,
530 },
531 {
532 title: "try to enable STARTTLS",
533 updateCfg: func(cfg *config.EmailConfig) {
534 cfg.RequireTLS = new(bool)
535 *cfg.RequireTLS = true
536 },
537
538 errMsg: "does not advertise the STARTTLS extension",
539 retry: true,
540 },
541 {
542 title: "invalid Hello string",
543 updateCfg: func(cfg *config.EmailConfig) {
544 cfg.AuthUsername = c.Username
545 cfg.AuthPassword = config.Secret(c.Password)
546 cfg.Hello = "invalid hello string"
547 },
548
549 errMsg: "501 Error",
550 retry: true,
551 },
552 } {
553 tc := tc
554 t.Run(tc.title, func(t *testing.T) {
555 emailCfg := &config.EmailConfig{
556 Smarthost: c.Smarthost,
557 To: emailTo,
558 From: emailFrom,
559 HTML: "HTML body",
560 Text: "Text body",
561 Headers: map[string]string{
562 "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
563 },
564 }
565 if tc.updateCfg != nil {
566 tc.updateCfg(emailCfg)
567 }
568
569 e, retry, err := notifyEmail(emailCfg, c.Server)
570 if len(tc.errMsg) > 0 {
571 require.Error(t, err)
572 require.Contains(t, err.Error(), tc.errMsg)
573 require.Equal(t, tc.retry, retry)
574 return
575 }
576 require.NoError(t, err)
577
578 require.Equal(t, "1 firing alert(s)", e.Subject)
579
580 getAddresses := func(addresses []map[string]string) []string {
581 res := make([]string, 0, len(addresses))
582 for _, addr := range addresses {
583 res = append(res, addr["address"])
584 }
585 return res
586 }
587 to := getAddresses(e.To)
588 from := getAddresses(e.From)
589 require.Equal(t, strings.Split(emailCfg.To, ","), to)
590 require.Equal(t, strings.Split(emailCfg.From, ","), from)
591
592 if len(emailCfg.HTML) > 0 {
593 require.Equal(t, emailCfg.HTML, *e.HTML)
594 } else {
595 require.Nil(t, e.HTML)
596 }
597
598 if len(emailCfg.Text) > 0 {
599 require.Equal(t, emailCfg.Text, *e.Text)
600 } else {
601 require.Nil(t, e.Text)
602 }
603 })
604 }
605 }
606
607 func TestEmailConfigNoAuthMechs(t *testing.T) {
608 email := &Email{
609 conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
610 }
611 _, err := email.auth("")
612 require.Error(t, err)
613 require.Equal(t, err.Error(), "unknown auth mechanism: ")
614 }
615
616 func TestEmailConfigMissingAuthParam(t *testing.T) {
617 conf := &config.EmailConfig{AuthUsername: "test"}
618 email := &Email{
619 conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(),
620 }
621 _, err := email.auth("CRAM-MD5")
622 require.Error(t, err)
623 require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism")
624
625 _, err = email.auth("PLAIN")
626 require.Error(t, err)
627 require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism")
628
629 _, err = email.auth("LOGIN")
630 require.Error(t, err)
631 require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism")
632
633 _, err = email.auth("PLAIN LOGIN")
634 require.Error(t, err)
635 require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism")
636 }
637
638 func TestEmailNoUsernameStillOk(t *testing.T) {
639 email := &Email{
640 conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
641 }
642 a, err := email.auth("CRAM-MD5")
643 require.NoError(t, err)
644 require.Nil(t, a)
645 }
646
View as plain text