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
+203
View File
@@ -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) {