Release v0.1.0-preview.28: transactional account email
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.
This commit is contained in:
2026-09-11 04:12:17 -04:00
parent ebcbbf06f5
commit 494b72fa3b
18 changed files with 3175 additions and 47 deletions
+32
View File
@@ -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
View File
@@ -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
}
+354
View File
@@ -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
View File
@@ -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
}