feat: report which SMTP phase a send failed in

This commit is contained in:
2026-09-11 14:12:36 +00:00
parent f65174a368
commit fbba9de256
2 changed files with 242 additions and 13 deletions
+39 -13
View File
@@ -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