diff --git a/mail/sender.go b/mail/sender.go index 9f5eaa7..ceef388 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -30,6 +30,29 @@ import ( // client gives up - and admin's signup rollback runs on that request's context. const timeout = 15 * time.Second +// SMTP send phases. A caller that retries per recipient needs to know which +// one failed: a recipient or data reply names one person's problem, while a +// connect or quit failure says nothing about the message itself and a data +// failure with no reply means the outcome of that one send is unknown. +const ( + PhasePrepare = "prepare" + PhaseConnect = "connect" + PhaseRecipient = "recipient" + PhaseData = "data" + PhaseQuit = "quit" +) + +// SendError names which phase of the SMTP conversation failed. Err is kept as +// the original wrapped error, so errors.As(err, &textprotoErr) still reaches +// a *textproto.Error through Unwrap when the server sent one. +type SendError struct { + Phase string + Err error +} + +func (e *SendError) Error() string { return "smtp " + e.Phase + ": " + e.Err.Error() } +func (e *SendError) Unwrap() error { return e.Err } + // Sender is a configured SMTP destination. It is a value, not a singleton: // server/internal/notify builds one per notification channel from data in // Mongo, while admin and sitesvc build one at boot. @@ -104,17 +127,17 @@ func (s Sender) sendTemplate(to, replyTo, name string, data any) error { // every admin email from being delivered once already. func (s Sender) send(m message) error { if !s.Enabled() { - return fmt.Errorf("smtp: not configured") + return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: not configured")} } rcpts := recipients(m.To) if len(rcpts) == 0 { - return fmt.Errorf("smtp: no recipient") + return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: no recipient")} } addr := net.JoinHostPort(s.Host, s.Port) conn, err := net.DialTimeout("tcp", addr, timeout) if err != nil { - return fmt.Errorf("smtp: dial %s: %w", addr, err) + return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: dial %s: %w", addr, err)} } _ = conn.SetDeadline(time.Now().Add(timeout)) @@ -125,49 +148,52 @@ func (s Sender) send(m message) error { client, err := smtp.NewClient(conn, s.Host) if err != nil { conn.Close() - return fmt.Errorf("smtp: client: %w", err) + return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: client: %w", err)} } defer client.Close() if s.Port != "465" { if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { - return fmt.Errorf("smtp: starttls: %w", err) + return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: starttls: %w", err)} } } } if s.Username != "" { if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { - return fmt.Errorf("smtp: auth: %w", err) + return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: auth: %w", err)} } } if err := client.Mail(addrSpec(s.From)); err != nil { - return fmt.Errorf("smtp: mail from: %w", err) + return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: mail from: %w", err)} } for _, rcpt := range rcpts { if err := client.Rcpt(addrSpec(rcpt)); err != nil { - return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err) + return &SendError{Phase: PhaseRecipient, Err: fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)} } } body, err := s.envelope(m) if err != nil { - return fmt.Errorf("smtp: build message: %w", err) + return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: build message: %w", err)} } w, err := client.Data() if err != nil { - return fmt.Errorf("smtp: data: %w", err) + return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: data: %w", err)} } if _, err := w.Write(body); err != nil { - return fmt.Errorf("smtp: write: %w", err) + return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: write: %w", err)} } if err := w.Close(); err != nil { - return fmt.Errorf("smtp: close data: %w", err) + return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: close data: %w", err)} } - return client.Quit() + if err := client.Quit(); err != nil { + return &SendError{Phase: PhaseQuit, Err: fmt.Errorf("smtp: quit: %w", err)} + } + return nil } // addrSpec is the bare address for the SMTP envelope. SMTP_FROM is usually diff --git a/mail/sender_test.go b/mail/sender_test.go index fa776ff..8c179be 100644 --- a/mail/sender_test.go +++ b/mail/sender_test.go @@ -1,16 +1,219 @@ package mail import ( + "bufio" "bytes" + "errors" "io" "mime" "mime/multipart" "mime/quotedprintable" + "net" "net/mail" + "net/textproto" "strings" "testing" ) +// fakeSMTP is a tiny scripted SMTP server. script maps an uppercased command +// verb (or "DATA_BODY" for the reply after the body's terminating dot, or +// "CLOSE_AFTER_BODY" to drop the connection with no reply at all) to the +// line(s) to write back. Anything not listed gets a plain 250 OK. +func fakeSMTP(t *testing.T, script map[string]string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + w := bufio.NewWriter(conn) + r := bufio.NewReader(conn) + writeLine := func(s string) { + w.WriteString(s + "\r\n") + w.Flush() + } + writeLine("220 fake.example ESMTP") + inData := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + if inData { + if line == "." { + inData = false + if reply, ok := script["DATA_BODY"]; ok { + if reply == "CLOSE" { + return + } + writeLine(reply) + } else { + writeLine("250 OK") + } + } + continue + } + verb := strings.ToUpper(strings.Fields(line)[0]) + if verb == "EHLO" || verb == "HELO" { + writeLine("250 fake.example") + continue + } + if verb == "DATA" { + if reply, ok := script["DATA"]; ok { + if reply == "CLOSE" { + return + } + writeLine(reply) + continue + } + inData = true + writeLine("354 go ahead") + continue + } + if verb == "QUIT" { + if reply, ok := script["QUIT"]; ok { + if reply == "CLOSE" { + return + } + writeLine(reply) + } else { + writeLine("221 bye") + } + return + } + if reply, ok := script[verb]; ok { + if reply == "CLOSE" { + return + } + writeLine(reply) + continue + } + writeLine("250 OK") + } + }() + + host, port, _ := net.SplitHostPort(ln.Addr().String()) + _ = host + return port +} + +func testMsg() message { + return message{To: "rcpt@example.com", Subject: "s", Text: "t", HTML: "
h
"} +} + +func TestSendPhaseOnRecipientFailure(t *testing.T) { + port := fakeSMTP(t, map[string]string{"RCPT": "550 no such user"}) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SendError", err) + } + if se.Phase != PhaseRecipient { + t.Fatalf("phase = %q, want %q", se.Phase, PhaseRecipient) + } + var tp *textproto.Error + if !errors.As(err, &tp) || tp.Code != 550 { + t.Fatalf("textproto reply = %+v", tp) + } +} + +func TestSendPhaseOnMailFromFailure(t *testing.T) { + // No AUTH configured, so a MAIL-stage 550 exercises the connect phase + // without needing to script a real AUTH challenge/response. + port := fakeSMTP(t, map[string]string{"MAIL": "550 relay denied"}) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SendError", err) + } + if se.Phase != PhaseConnect { + t.Fatalf("phase = %q, want %q", se.Phase, PhaseConnect) + } + var tp *textproto.Error + if !errors.As(err, &tp) || tp.Code != 550 { + t.Fatalf("textproto reply = %+v", tp) + } +} + +func TestSendPhaseOnDataCommandFailure(t *testing.T) { + port := fakeSMTP(t, map[string]string{"DATA": "554 no thanks"}) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SendError", err) + } + if se.Phase != PhaseData { + t.Fatalf("phase = %q, want %q", se.Phase, PhaseData) + } + var tp *textproto.Error + if !errors.As(err, &tp) || tp.Code != 554 { + t.Fatalf("textproto reply = %+v", tp) + } +} + +// The connection drops after the body's terminating dot but before any reply +// is read: the data phase, but with no textproto error, since the server +// never spoke back at all. +func TestSendPhaseOnDataNoReply(t *testing.T) { + port := fakeSMTP(t, map[string]string{"DATA_BODY": "CLOSE"}) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SendError", err) + } + if se.Phase != PhaseData { + t.Fatalf("phase = %q, want %q", se.Phase, PhaseData) + } + var tp *textproto.Error + if errors.As(err, &tp) { + t.Fatalf("expected no textproto reply, got %+v", tp) + } +} + +// A 250 on DATA_BODY (the message is accepted) followed by a dropped +// connection on QUIT: the message was already delivered, so this must be the +// quit phase, not data or connect. +func TestSendPhaseOnQuitFailure(t *testing.T) { + port := fakeSMTP(t, map[string]string{"QUIT": "CLOSE"}) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) { + t.Fatalf("err = %v, want *SendError", err) + } + if se.Phase != PhaseQuit { + t.Fatalf("phase = %q, want %q", se.Phase, PhaseQuit) + } +} + +func TestSendPrepareErrors(t *testing.T) { + s := Sender{} + err := s.send(testMsg()) + var se *SendError + if !errors.As(err, &se) || se.Phase != PhasePrepare { + t.Fatalf("not configured: err = %v", err) + } + + s2 := Sender{Host: "127.0.0.1", Port: "0", From: "updates@example.com"} + err2 := s2.send(message{Subject: "s", Text: "t", HTML: "h
"}) + var se2 *SendError + if !errors.As(err2, &se2) || se2.Phase != PhasePrepare { + t.Fatalf("no recipient: err = %v", err2) + } +} + // The envelope carries the bare address. "Vantage