feat: report which SMTP phase a send failed in
This commit is contained in:
+39
-13
@@ -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
|
||||
|
||||
@@ -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: "<p>h</p>"}
|
||||
}
|
||||
|
||||
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: "<p>h</p>"})
|
||||
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 <x@y>" as MAIL FROM left
|
||||
// rspamd with no envelope sender, and mailcow skipped DKIM signing.
|
||||
func TestAddrSpecStripsDisplayName(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user