256 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|