verify / verify (push) Successful in 4m29s
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.
355 lines
12 KiB
Go
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")
|
|
}
|
|
}
|