Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/smtp_classify_test.go
T

256 lines
8.6 KiB
Go

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"
"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
}
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() }
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
}
}
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
// (<send id>@<the account's domain>). 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: <send-abc123@novox.be>") {
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: <send-1@localhost>") {
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: <send-xyz@novox.be>") {
t.Fatalf("the message sent does not carry the send id's Message-ID:\n%s", got.data)
}
}