// 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 " }, 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 ", 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 != "" || capture.recipient != "" { 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") } }