Files
web/mail/mail_test.go
T
gamertan 494b72fa3b
verify / verify (push) Successful in 4m29s
Release v0.1.0-preview.28: transactional account email
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
2026-09-11 04:12:17 -04:00

355 lines
12 KiB
Go

// SPDX-License-Identifier: MPL-2.0
package mail
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
"mime"
"mime/quotedprintable"
"net"
stdmail "net/mail"
"net/textproto"
"strconv"
"strings"
"testing"
"time"
)
func sampleMessage() Message {
return Message{ID: "mail_fixture_1234567890", To: "reader@example.test", Subject: "Your account — a little care ♥", Text: "Hello!\nConfirm your email: https://example.test/verify/?token=disposable\n.One line with a dot.\n", CreatedAt: time.Date(2026, 9, 11, 5, 0, 0, 0, time.UTC)}
}
func TestMessageEncodingAndBounds(t *testing.T) {
from := &stdmail.Address{Name: "Cole ♥", Address: "support@example.test"}
message := sampleMessage()
if strings.Contains(fmt.Sprintf("%+v %#v", message, message), "disposable") {
t.Fatal("message debug output exposed a token")
}
encoded, err := encode(message, from)
if err != nil {
t.Fatal(err)
}
parsed, err := stdmail.ReadMessage(bytes.NewReader(encoded))
if err != nil {
t.Fatal(err)
}
subject, err := (&mime.WordDecoder{}).DecodeHeader(parsed.Header.Get("Subject"))
if err != nil || subject != message.Subject {
t.Fatal("subject did not round-trip")
}
if parsed.Header.Get("Message-ID") != "<"+message.ID+"@example.test>" {
t.Fatal("unstable message ID")
}
if _, err = parsed.Header.Date(); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(parsed.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != message.Text {
t.Fatal("body did not round-trip")
}
for _, line := range strings.Split(string(encoded), "\r\n") {
if len(line) > 998 || strings.ContainsRune(line, '\n') {
t.Fatal("invalid MIME line")
}
}
for _, mutate := range []func(*Message){
func(m *Message) { m.ID = "bad\r\nID" }, func(m *Message) { m.To = "a@example.test,b@example.test" }, func(m *Message) { m.To = "Reader <reader@example.test>" },
func(m *Message) { m.To = "\"line\r\nbreak\"@example.test" }, func(m *Message) { m.To = "élève@example.test" }, func(m *Message) { m.Subject = "Subject\r\nBcc: other@example.test" },
func(m *Message) { m.Subject = strings.Repeat("a", 257) }, func(m *Message) { m.Text = strings.Repeat("x", MaxTextBytes+1) }, func(m *Message) { m.Text = "bad\x00body" },
func(m *Message) { m.Text = string([]byte{0xff}) }, func(m *Message) { m.CreatedAt = time.Time{} },
} {
bad := sampleMessage()
mutate(&bad)
if _, err := encode(bad, from); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message accepted")
}
}
}
type smtpFixtureOptions struct {
mode TLSMode
noSTARTTLS, rejectAuth, rejectRecipient, temporary, closeAfterAccept, stall bool
connected chan struct{}
}
type smtpCapture struct {
commands []string
authenticatedTLS bool
sender, recipient, body string
accepted bool
}
func smtpFixture(t *testing.T, options smtpFixtureOptions) (SMTPConfig, <-chan smtpCapture) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Now()
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(certificate)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
_, portText, _ := net.SplitHostPort(listener.Addr().String())
port, _ := strconv.Atoi(portText)
result := make(chan smtpCapture, 1)
go func() {
capture := smtpCapture{}
defer func() { result <- capture }()
connection, err := listener.Accept()
if err != nil {
return
}
defer connection.Close()
if options.connected != nil {
close(options.connected)
}
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
secure := options.mode == ImplicitTLS
if secure {
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
}
protocol := textproto.NewConn(connection)
if options.stall {
_, _ = protocol.ReadLine()
return
}
if protocol.PrintfLine("220 localhost test mail") != nil {
return
}
for {
line, err := protocol.ReadLine()
if err != nil {
return
}
verb, _, _ := strings.Cut(line, " ")
capture.commands = append(capture.commands, verb)
switch verb {
case "EHLO":
if !secure && !options.noSTARTTLS {
err = protocol.PrintfLine("250-localhost\r\n250 STARTTLS")
} else {
err = protocol.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
}
case "STARTTLS":
if protocol.PrintfLine("220 Ready for TLS") != nil {
return
}
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
protocol = textproto.NewConn(connection)
secure = true
case "AUTH":
capture.authenticatedTLS = secure
if line != "AUTH PLAIN "+base64.StdEncoding.EncodeToString([]byte("\x00app-sender\x00fixture-password")) || options.rejectAuth {
err = protocol.PrintfLine("535 credential-detail-must-not-leak")
} else {
err = protocol.PrintfLine("235 Authenticated")
}
case "MAIL":
capture.sender = strings.TrimPrefix(line, "MAIL FROM:")
err = protocol.PrintfLine("250 Sender accepted")
case "RCPT":
capture.recipient = strings.TrimPrefix(line, "RCPT TO:")
if options.rejectRecipient {
code := 550
if options.temporary {
code = 450
}
err = protocol.PrintfLine("%d recipient-detail-must-not-leak", code)
} else {
err = protocol.PrintfLine("250 Recipient accepted")
}
case "DATA":
if protocol.PrintfLine("354 Send message") != nil {
return
}
body, readErr := io.ReadAll(io.LimitReader(protocol.DotReader(), MaxTextBytes*2))
if readErr != nil {
return
}
capture.body = string(body)
capture.accepted = true
err = protocol.PrintfLine("250 Accepted")
if options.closeAfterAccept {
return
}
case "QUIT":
_ = protocol.PrintfLine("221 Goodbye")
return
default:
err = protocol.PrintfLine("500 Unexpected command")
}
if err != nil {
return
}
}
}()
return SMTPConfig{Host: "localhost", Port: port, TLSMode: options.mode, Username: "app-sender", Password: "fixture-password", From: "Cole <support@example.test>", Timeout: 3 * time.Second, RootCAs: roots}, result
}
func takeCapture(t *testing.T, result <-chan smtpCapture) smtpCapture {
t.Helper()
select {
case capture := <-result:
return capture
case <-time.After(6 * time.Second):
t.Fatal("SMTP fixture did not finish")
return smtpCapture{}
}
}
func TestSMTPSecureDelivery(t *testing.T) {
for _, mode := range []TLSMode{ImplicitTLS, RequiredSTARTTLS} {
for _, closeAfterAccept := range []bool{false, true} {
t.Run(string(mode)+"/quit="+strconv.FormatBool(closeAfterAccept), func(t *testing.T) {
config, result := smtpFixture(t, smtpFixtureOptions{mode: mode, closeAfterAccept: closeAfterAccept})
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
if err = transport.Send(t.Context(), sampleMessage()); err != nil {
t.Fatal(err)
}
capture := takeCapture(t, result)
if !capture.authenticatedTLS || !capture.accepted || capture.sender != "<support@example.test>" || capture.recipient != "<reader@example.test>" {
t.Fatal("SMTP identity or TLS boundary failed")
}
message, err := stdmail.ReadMessage(bufio.NewReader(strings.NewReader(capture.body)))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(message.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != sampleMessage().Text {
t.Fatal("SMTP body changed")
}
})
}
}
}
func TestSMTPRejectsDowngradeCertificatesAndProviderErrors(t *testing.T) {
for _, tc := range []struct {
name string
options smtpFixtureOptions
untrusted, wrongHost bool
stage string
code int
retry bool
}{
{name: "no STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS, noSTARTTLS: true}, stage: "starttls-required"},
{name: "untrusted implicit TLS", options: smtpFixtureOptions{mode: ImplicitTLS}, untrusted: true, stage: "tls"},
{name: "untrusted STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS}, untrusted: true, stage: "tls"},
{name: "wrong hostname", options: smtpFixtureOptions{mode: ImplicitTLS}, wrongHost: true, stage: "tls"},
{name: "bad credential", options: smtpFixtureOptions{mode: ImplicitTLS, rejectAuth: true}, stage: "authentication", code: 535},
{name: "bad recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true}, stage: "recipient", code: 550},
{name: "temporary recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true, temporary: true}, stage: "recipient", code: 450, retry: true},
} {
t.Run(tc.name, func(t *testing.T) {
config, result := smtpFixture(t, tc.options)
if tc.untrusted {
config.RootCAs = x509.NewCertPool()
}
if tc.wrongHost {
config.Host = "127.0.0.1"
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
err = transport.Send(t.Context(), sampleMessage())
var failure *Error
if !errors.As(err, &failure) || failure.Stage != tc.stage || failure.Code != tc.code || failure.Retryable != tc.retry {
t.Fatalf("unexpected safe failure: %v", err)
}
if strings.Contains(err.Error(), "must-not-leak") || strings.Contains(err.Error(), "fixture-password") || strings.Contains(err.Error(), "reader@example.test") {
t.Fatal("SMTP response leaked")
}
capture := takeCapture(t, result)
if capture.accepted {
t.Fatal("rejected delivery accepted DATA")
}
if tc.code == 0 && capture.authenticatedTLS {
t.Fatal("authenticated despite failed secure negotiation")
}
})
}
}
func TestSMTPCancellationAndInvalidInput(t *testing.T) {
connected := make(chan struct{})
config, result := smtpFixture(t, smtpFixtureOptions{mode: RequiredSTARTTLS, stall: true, connected: connected})
if strings.Contains(fmt.Sprintf("%+v %#v", config, config), "fixture-password") {
t.Fatal("configuration debug output exposed a credential")
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
completed := make(chan error, 1)
go func() { completed <- transport.Send(ctx, sampleMessage()) }()
select {
case <-connected:
case <-time.After(5 * time.Second):
t.Fatal("fixture connection missing")
}
cancel()
select {
case err = <-completed:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error=%v", err)
}
case <-time.After(time.Second):
t.Fatal("SMTP cancellation did not stop promptly")
}
if capture := takeCapture(t, result); capture.accepted {
t.Fatal("cancelled send accepted")
}
for _, mutate := range []func(*SMTPConfig){func(c *SMTPConfig) { c.TLSMode = "plaintext" }, func(c *SMTPConfig) { c.Host = "bad\nserver" }, func(c *SMTPConfig) { c.From = "a@example.test,b@example.test" }, func(c *SMTPConfig) { c.Password = "" }, func(c *SMTPConfig) { c.Port = 0 }, func(c *SMTPConfig) { c.Timeout = time.Hour }} {
bad := config
mutate(&bad)
if _, err := NewSMTP(bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid SMTP config accepted")
}
}
bad := sampleMessage()
bad.To = "invalid"
if err := transport.Send(t.Context(), bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message reached transport")
}
}