package main // SendMail classifies every failure (novox/hq ADR 0436 clause 10, issue 1197): not taken (no connection, a // refused sign-in, or a 4xx reply to MAIL, RCPT or DATA or to the end of data), refused (a 5xx before or at // the end of data), or not proven (the connection lost after the data was written, before any reply). Its // Message-ID names the send id. import ( "bufio" "errors" "fmt" "net" "net/smtp" "strings" "sync/atomic" "testing" ) // step is one scripted SMTP reply, or a drop (the connection closed instead of a reply). type step struct { drop bool code int msg string } func reply(code int, msg string) step { return step{code: code, msg: msg} } func drop() step { return step{drop: true} } // scriptedSMTPServer answers EHLO, AUTH, MAIL, RCPT, DATA's own 354 and the end-of-data reply each with a // step a test sets, defaulting to the ordinary success sequence — so a test overrides exactly the one stage // its case is about. type scriptedSMTPServer struct { ln net.Listener ehlo, auth, mail, rcpt, dataOpen, dataEnd step received int32 // atomic: how many full DATA bodies this server has read } func newScriptedSMTPServer(t *testing.T) *scriptedSMTPServer { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } s := &scriptedSMTPServer{ln: ln, ehlo: reply(250, "fake.local"), auth: reply(235, "authenticated"), mail: reply(250, "OK"), rcpt: reply(250, "OK"), dataOpen: reply(354, "go ahead"), dataEnd: reply(250, "queued")} go s.serve() t.Cleanup(func() { _ = ln.Close() }) return s } func (s *scriptedSMTPServer) addr() string { return s.ln.Addr().String() } // receivedCount is how many full DATA bodies this server has read, for a test to prove nothing was // received at all (novox/hq review round 1 finding 6). func (s *scriptedSMTPServer) receivedCount() int { return int(atomic.LoadInt32(&s.received)) } func (s *scriptedSMTPServer) dialer() SMTPDialer { addr := s.addr() return func(Server) (*smtp.Client, error) { return smtp.Dial(addr) } } func (s *scriptedSMTPServer) send(conn net.Conn, st step) bool { if st.drop { _ = conn.Close() return false } fmt.Fprintf(conn, "%d %s\r\n", st.code, st.msg) return true } func (s *scriptedSMTPServer) serve() { for { conn, err := s.ln.Accept() if err != nil { return } go s.handle(conn) } } func (s *scriptedSMTPServer) handle(conn net.Conn) { defer conn.Close() r := bufio.NewReader(conn) fmt.Fprintf(conn, "220 fake.local ESMTP\r\n") for { line, err := r.ReadString('\n') if err != nil { return } upper := strings.ToUpper(strings.TrimRight(line, "\r\n")) switch { case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): if !s.send(conn, s.ehlo) { return } case strings.HasPrefix(upper, "AUTH"): if !s.send(conn, s.auth) { return } case strings.HasPrefix(upper, "MAIL"): if !s.send(conn, s.mail) { return } case strings.HasPrefix(upper, "RCPT"): if !s.send(conn, s.rcpt) { return } case upper == "DATA": if !s.send(conn, s.dataOpen) { return } if s.dataOpen.code >= 400 { continue // not entering data mode; the client ends the transaction itself } for { dl, err := r.ReadString('\n') if err != nil { return } if dl == ".\r\n" || dl == ".\n" { break } } atomic.AddInt32(&s.received, 1) // a full message body was read, whatever dataEnd then says s.send(conn, s.dataEnd) // a drop here is "the connection lost after the data was written" return case upper == "QUIT": fmt.Fprintf(conn, "221 bye\r\n") return default: fmt.Fprintf(conn, "500 unrecognized\r\n") } } } // classifyTestAccount is an account whose outgoing server is the given address: Host is its loopback // literal, which PlainAuth itself accepts without TLS (its own isLocalhost check). func classifyTestAccount(addr string) Account { host, portStr, _ := net.SplitHostPort(addr) var port int _, _ = fmt.Sscanf(portStr, "%d", &port) return Account{Name: "mesh", Address: "jschoubben@novox.be", Outgoing: &Server{Host: host, Port: port, Security: "tls"}} } func outcomeOf(t *testing.T, err error) SendOutcome { t.Helper() var se *SendError if !errors.As(err, &se) { t.Fatalf("SendMail's error is not a *SendError: %v (%T)", err, err) } return se.Outcome } // 1197/AC1: given no connection, a refused sign-in, or a 4xx reply to MAIL, RCPT or DATA or to the end of // data, when SendMail runs, then it says not taken with the server's words. func TestSendMailSaysNotTaken(t *testing.T) { t.Run("no connection", func(t *testing.T) { s := newScriptedSMTPServer(t) addr := s.addr() s.ln.Close() // nothing listens by the time SendMail dials acc := classifyTestAccount(addr) dial := func(Server) (*smtp.Client, error) { return smtp.Dial(addr) } err := SendMail(dial, acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1") if err == nil || outcomeOf(t, err) != NotTaken { t.Fatalf("err %v, want NotTaken", err) } }) t.Run("a refused sign-in", func(t *testing.T) { s := newScriptedSMTPServer(t) s.auth = reply(535, "authentication failed") acc := classifyTestAccount(s.addr()) err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1") if err == nil || outcomeOf(t, err) != NotTaken || !strings.Contains(err.Error(), "signing in") { t.Fatalf("err %v, want NotTaken naming the sign-in", err) } }) for _, c := range []struct { name string set func(*scriptedSMTPServer) }{ {"a 4xx to MAIL", func(s *scriptedSMTPServer) { s.mail = reply(451, "try again later") }}, {"a 4xx to RCPT", func(s *scriptedSMTPServer) { s.rcpt = reply(450, "mailbox busy") }}, {"a 4xx to DATA", func(s *scriptedSMTPServer) { s.dataOpen = reply(452, "insufficient storage") }}, {"a 4xx at the end of data", func(s *scriptedSMTPServer) { s.dataEnd = reply(451, "local error") }}, } { t.Run(c.name, func(t *testing.T) { s := newScriptedSMTPServer(t) c.set(s) acc := classifyTestAccount(s.addr()) err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1") if err == nil || outcomeOf(t, err) != NotTaken { t.Fatalf("err %v, want NotTaken", err) } }) } } // 1197/AC2: given a 5xx reply before or at the end of data, then it says refused with the server's words. func TestSendMailSaysRefused(t *testing.T) { for _, c := range []struct { name string set func(*scriptedSMTPServer) }{ {"a 5xx to MAIL", func(s *scriptedSMTPServer) { s.mail = reply(550, "no such sender") }}, {"a 5xx to RCPT", func(s *scriptedSMTPServer) { s.rcpt = reply(550, "no such recipient") }}, {"a 5xx to DATA", func(s *scriptedSMTPServer) { s.dataOpen = reply(554, "transaction failed") }}, {"a 5xx at the end of data", func(s *scriptedSMTPServer) { s.dataEnd = reply(552, "quota exceeded") }}, } { t.Run(c.name, func(t *testing.T) { s := newScriptedSMTPServer(t) c.set(s) acc := classifyTestAccount(s.addr()) err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1") if err == nil || outcomeOf(t, err) != Refused { t.Fatalf("err %v, want Refused", err) } }) } } // 1197/AC3: given the connection lost after the data was written and before any reply, then it says not // proven. func TestSendMailSaysNotProvenWhenTheConnectionIsLostAfterTheDataWasWritten(t *testing.T) { s := newScriptedSMTPServer(t) s.dataEnd = drop() acc := classifyTestAccount(s.addr()) err := SendMail(s.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-1") if err == nil || outcomeOf(t, err) != NotProven { t.Fatalf("err %v, want NotProven", err) } } // 1197/AC4: given a send id, when the message is composed, then it carries a Message-ID naming the send id // (@). Proved both in isolation (composeMessage) and over the wire // (SendMail, with the existing fakeSMTPServer, which keeps the body it received). func TestComposeMessageCarriesTheSendIDInItsMessageID(t *testing.T) { raw := composeMessage("jschoubben@novox.be", []string{"a@b.c"}, "s", "body", judgedAt, "send-abc123") if !strings.Contains(string(raw), "Message-Id: ") { t.Fatalf("the message does not carry the send id's Message-ID:\n%s", raw) } } // composeMessage falls back to "localhost" when From carries no domain at all (never an empty or // malformed Message-ID). func TestComposeMessageFallsBackToLocalhostWithNoDomain(t *testing.T) { raw := composeMessage("postmaster", nil, "s", "body", judgedAt, "send-1") if !strings.Contains(string(raw), "Message-Id: ") { t.Fatalf("message: %s", raw) } } func TestSendMailCarriesTheMessageIDOverTheWire(t *testing.T) { fake := newFakeSMTPServer(t) acc := classifyTestAccount(fake.addr()) if err := SendMail(fake.dialer(), acc, "pw", []string{"a@b.c"}, "s", "body", judgedAt, "send-xyz"); err != nil { t.Fatalf("SendMail: %v", err) } got := fake.last() if !strings.Contains(got.data, "Message-Id: ") { t.Fatalf("the message sent does not carry the send id's Message-ID:\n%s", got.data) } }