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.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Transactional mail transport
|
||||
|
||||
`mail.Message` is one bounded UTF-8 plain-text message with a stable ID/date and
|
||||
one ASCII envelope recipient. `NewSMTP` binds a fixed, configured sender and
|
||||
dedicated SMTP credentials. Use implicit TLS (normally465) or required STARTTLS
|
||||
(normally587). Certificate chain and hostname verification are mandatory; no
|
||||
plaintext fallback or caller-provided arbitrary headers/attachments exist.
|
||||
|
||||
The standard-library transport uses a bounded connection deadline and cancellation
|
||||
across TLS and SMTP. Message IDs survive retries. Errors expose only a fixed
|
||||
stage, numeric SMTP code and retry classification; raw server responses are not
|
||||
propagated because they can contain addresses or credentials. Successful DATA
|
||||
acceptance is success even if QUIT fails. A connection failure around acceptance
|
||||
can still produce duplicate delivery on retry: SMTP is not exactly-once transport,
|
||||
and acceptance is not proof of inbox placement.
|
||||
|
||||
Applications own authorized recipients, email templates, trusted HTTPS link
|
||||
origins, rate limits, encrypted persistence and the worker. Never log message
|
||||
content or serialize credentials into ordinary diagnostics. Debug string methods
|
||||
redact sensitive content but are not a substitute for safe logging policy.
|
||||
No account verification/reset protocol is implemented by this transport itself.
|
||||
|
||||
Verified locally with disposable SMTP servers: implicit/STARTTLS delivery,
|
||||
certificate/hostname rejection, no-downgrade behavior, auth/recipient error
|
||||
classification, cancellation after connection, MIME round-trip, injection/bounds
|
||||
and acceptance followed by QUIT failure. `go test -race ./mail` and `go vet ./mail`
|
||||
pass on Go1.26.6. No real credentials, message delivery, public package release or
|
||||
consumer deployment are claimed. The [encrypted outbox](../mailsqlite/README.md)
|
||||
adds transactional persistence; [authmail](../authmail/README.md) supplies optional
|
||||
account protocols with consumer-owned forms and authorization boundaries.
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package mail provides bounded, single-recipient transactional messages.
|
||||
// Applications own authorization, templates, trusted link origins and consent.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
stdmail "net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxTextBytes = 64 << 10
|
||||
|
||||
var ErrInvalid = errors.New("mail: invalid message or configuration")
|
||||
var messageIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{15,63}$`)
|
||||
|
||||
// Message has no caller-controlled From, arbitrary headers or recipient list.
|
||||
// ID and CreatedAt stay stable across delivery retries. Treat Text as sensitive:
|
||||
// never log it, and encrypt it when persisted (it may contain a recovery link).
|
||||
type Message struct {
|
||||
ID, To, Subject, Text string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// String and GoString avoid accidental plaintext recovery-link logging during
|
||||
// debugging. Structured serializers still require the same care as any secret.
|
||||
func (Message) String() string { return "mail.Message{content:redacted}" }
|
||||
func (message Message) GoString() string { return message.String() }
|
||||
|
||||
type Transport interface {
|
||||
// Send returns nil after the SMTP server accepts DATA, not after inbox delivery.
|
||||
// Implementations must honor context cancellation and deadlines. The outbox
|
||||
// relies on this bound to finish transmission before its worker lease expires.
|
||||
Send(context.Context, Message) error
|
||||
}
|
||||
|
||||
func (message Message) Validate() error {
|
||||
if !messageIDPattern.MatchString(message.ID) || !validMailbox(message.To) ||
|
||||
!headerText(message.Subject, 256) || message.CreatedAt.Year() < 1970 || message.CreatedAt.Year() > 9999 ||
|
||||
len(message.Text) == 0 || len(message.Text) > MaxTextBytes || !utf8.ValidString(message.Text) {
|
||||
return ErrInvalid
|
||||
}
|
||||
for _, r := range message.Text {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func headerText(value string, maximum int) bool {
|
||||
if strings.TrimSpace(value) == "" || len(value) > maximum || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// This transport uses ASCII envelope addresses, not SMTPUTF8. Display names and
|
||||
// subjects may be UTF-8; MIME encodes them. The application owns normalization.
|
||||
func validMailbox(value string) bool {
|
||||
if len(value) > 254 || !headerText(value, 254) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
parsed, err := stdmail.ParseAddress(value)
|
||||
return err == nil && parsed.Name == "" && parsed.Address == value && strings.Contains(value, "@")
|
||||
}
|
||||
|
||||
func encode(message Message, from *stdmail.Address) ([]byte, error) {
|
||||
if err := message.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
domain := from.Address[strings.LastIndexByte(from.Address, '@')+1:]
|
||||
fmt.Fprintf(&buffer, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%s@%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", from.String(), (&stdmail.Address{Address: message.To}).String(), mime.QEncoding.Encode("UTF-8", message.Subject), message.CreatedAt.UTC().Format(time.RFC1123Z), message.ID, domain)
|
||||
body := strings.ReplaceAll(strings.ReplaceAll(message.Text, "\r\n", "\n"), "\r", "\n")
|
||||
writer := quotedprintable.NewWriter(&buffer)
|
||||
if _, err := writer.Write([]byte(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.HasSuffix(buffer.Bytes(), []byte("\r\n")) {
|
||||
buffer.WriteString("\r\n")
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
stdmail "net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
ImplicitTLS TLSMode = "implicit"
|
||||
RequiredSTARTTLS TLSMode = "starttls"
|
||||
)
|
||||
|
||||
// SMTPConfig is trusted server configuration, never an HTTP payload. Keep its
|
||||
// credentials in private operator-managed storage and do not log this value.
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
TLSMode TLSMode
|
||||
Username, Password, From string
|
||||
Timeout time.Duration
|
||||
// RootCAs supplies an explicit trust pool (for example private PKI).
|
||||
// Nil uses system roots. Hostname verification cannot be disabled.
|
||||
RootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
func (SMTPConfig) String() string { return "mail.SMTPConfig{credentials:redacted}" }
|
||||
func (config SMTPConfig) GoString() string { return config.String() }
|
||||
|
||||
type SMTP struct {
|
||||
address, host, username, password string
|
||||
from *stdmail.Address
|
||||
tlsMode TLSMode
|
||||
timeout time.Duration
|
||||
rootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
func (*SMTP) String() string { return "mail.SMTP{credentials:redacted}" }
|
||||
func (transport *SMTP) GoString() string { return transport.String() }
|
||||
|
||||
// Error contains only a fixed stage and numeric SMTP code, never the server's
|
||||
// response (which can echo a mailbox, authentication token or message content).
|
||||
type Error struct {
|
||||
Stage string
|
||||
Code int
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (err *Error) Error() string {
|
||||
return fmt.Sprintf("mail: SMTP %s failed (code %d)", err.Stage, err.Code)
|
||||
}
|
||||
|
||||
func NewSMTP(config SMTPConfig) (*SMTP, error) {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 15 * time.Second
|
||||
}
|
||||
if !headerText(config.Host, 253) || strings.ContainsAny(config.Host, " \t\r\n/@[]\\") || config.Port < 1 || config.Port > 65535 ||
|
||||
(config.TLSMode != ImplicitTLS && config.TLSMode != RequiredSTARTTLS) || config.Timeout < time.Second || config.Timeout > 30*time.Second ||
|
||||
!headerText(config.Username, 254) || config.Password == "" || len(config.Password) > 4096 || strings.ContainsAny(config.Password, "\x00\r\n") || !headerText(config.From, 320) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
from, err := stdmail.ParseAddress(config.From)
|
||||
if err != nil || !validMailbox(from.Address) || from.Name != "" && !headerText(from.Name, 128) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
var roots *x509.CertPool
|
||||
if config.RootCAs != nil {
|
||||
roots = config.RootCAs.Clone()
|
||||
}
|
||||
return &SMTP{address: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), host: config.Host, username: config.Username, password: config.Password, from: from, tlsMode: config.TLSMode, timeout: config.Timeout, rootCAs: roots}, nil
|
||||
}
|
||||
|
||||
func failure(ctx context.Context, stage string, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
result := &Error{Stage: stage, Retryable: true}
|
||||
var protocol *textproto.Error
|
||||
if errors.As(err, &protocol) {
|
||||
result.Code = protocol.Code
|
||||
result.Retryable = protocol.Code >= 400 && protocol.Code < 500
|
||||
}
|
||||
var unknownAuthority x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var invalidCertificate x509.CertificateInvalidError
|
||||
if errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalidCertificate) {
|
||||
result.Retryable = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Send never falls back to plaintext, even on localhost. Cancellation closes the
|
||||
// connection and every protocol step shares one bounded deadline. SMTP can accept
|
||||
// DATA just before a connection failure; retrying that uncertainty may duplicate
|
||||
// mail. A failure of QUIT after DATA acceptance does not trigger another send.
|
||||
func (transport *SMTP) Send(ctx context.Context, message Message) error {
|
||||
if transport == nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
payload, err := encode(message, transport.from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, transport.timeout)
|
||||
defer cancel()
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", transport.address)
|
||||
if err != nil {
|
||||
return failure(ctx, "connect", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
|
||||
defer stop()
|
||||
deadline, _ := ctx.Deadline()
|
||||
if err = connection.SetDeadline(deadline); err != nil {
|
||||
return failure(ctx, "connect", err)
|
||||
}
|
||||
tlsConfig := &tls.Config{ServerName: transport.host, MinVersion: tls.VersionTLS12, RootCAs: transport.rootCAs}
|
||||
var wire net.Conn = connection
|
||||
if transport.tlsMode == ImplicitTLS {
|
||||
secure := tls.Client(connection, tlsConfig)
|
||||
if err = secure.HandshakeContext(ctx); err != nil {
|
||||
return failure(ctx, "tls", err)
|
||||
}
|
||||
wire = secure
|
||||
}
|
||||
client, err := smtp.NewClient(wire, transport.host)
|
||||
if err != nil {
|
||||
return failure(ctx, "greeting", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if transport.tlsMode == RequiredSTARTTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return &Error{Stage: "starttls-required"}
|
||||
}
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return failure(ctx, "tls", err)
|
||||
}
|
||||
}
|
||||
if err = client.Auth(smtp.PlainAuth("", transport.username, transport.password, transport.host)); err != nil {
|
||||
return failure(ctx, "authentication", err)
|
||||
}
|
||||
if err = client.Mail(transport.from.Address); err != nil {
|
||||
return failure(ctx, "sender", err)
|
||||
}
|
||||
if err = client.Rcpt(message.To); err != nil {
|
||||
return failure(ctx, "recipient", err)
|
||||
}
|
||||
data, err := client.Data()
|
||||
if err != nil {
|
||||
return failure(ctx, "data", err)
|
||||
}
|
||||
if _, err = data.Write(payload); err != nil {
|
||||
return failure(ctx, "data", err)
|
||||
}
|
||||
if err = data.Close(); err != nil {
|
||||
return failure(ctx, "acceptance", err)
|
||||
}
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user