Sending

SMTP

The one Sender the library ships, one session per message, and how to test it without a real mail server.

lttr.SMTP is a Sender that delivers each message over its own SMTP session — no connection pooling, no batching, one net/smtp conversation per Send call.

Configure it

type SMTP struct {
    Addr    string
    Auth    smtp.Auth
    Headers map[string]string
    Now     func() time.Time
}
  • Addr is the server's host:port, e.g. "smtp.example.com:587".
  • Auth authenticates the session (smtp.PlainAuth, smtp.CRAMMD5Auth, or a custom smtp.Auth). Leave it nil to send without authentication.
  • Headers are added to every message this SMTP sends — the natural place for a provider-specific header like X-SES-CONFIGURATION-SET or X-Mailgun-Tag. They go through the same checks as Message.SetHeader (reserved names, CR/LF/NUL, printable ASCII), applied to a copy of the message, so the same *Message sent through two different SMTP values never cross-contaminates their headers.
  • Now stamps the Date header; nil means time.Now. Set it in tests for deterministic output.
main.go
package main

import (
    "context"
    "fmt"
    "net/mail"

    "github.com/sulv-io/lttr"
)

func main() {
    s := &lttr.SMTP{
        Addr: "smtp.example.com:587",
        Headers: map[string]string{
            "X-SES-CONFIGURATION-SET": "transactional",
        },
    }

    m := &lttr.Message{
        From:    mail.Address{Name: "CaribHubs", Address: "orders@caribhubs.com"},
        To:      mail.Address{Name: "Keisha Browne", Address: "keisha@example.com"},
        Subject: "Your order is on its way",
        HTML:    "<p>On its way.</p>",
        Text:    "On its way.",
    }

    if err := s.Send(context.Background(), m); err != nil {
        fmt.Println("send failed:", err)
    }
}

Follow one session

Send runs a fixed sequence per call:

  1. Clone m, add s.Headers to the copy, and serialise it with MIME — before any network connection is made, so a message that cannot be encoded never opens a socket.
  2. Split the host out of Addr and dial it (net.Dialer.DialContext, so ctx governs the dial too).
  3. EHLO localhost.
  4. STARTTLS if the server advertises the extension: tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, where host is the host parsed out of Addr — the certificate is checked against the server you asked for, not whatever the server claims to be.
  5. AUTH, only if s.Auth is set. If it is set but the server does not offer the AUTH extension, that is an error (auth is configured but the server does not offer it) — configured credentials the server never asked for fail loudly rather than silently sending unauthenticated.
  6. MAIL FROM:<{m.From.Address}>, RCPT TO:<{m.To.Address}>, DATA, then the serialised message.
  7. QUIT after DATA succeeds. A failed QUIT is deliberately ignored: the server already accepted the message, so surfacing the error would make a caller who retries on error send it twice. The connection is torn down by a deferred Close either way.

Cancelling ctx at any point aborts the dial or unblocks whatever read or write the session is waiting on (context.AfterFunc sets a past deadline on the connection), and the returned error wraps ctx.Err().

Test it with a fake server

Send needs nothing more than a net.Listener that speaks enough ESMTP to satisfy net/smtp: EHLO, optionally STARTTLS and AUTH, MAIL, RCPT, DATA, QUIT. No real network access or mail server is needed.

smtp_test.go
// A minimal in-process ESMTP server: enough for net/smtp to complete a
// session against, recording what the client sent. The repo's own tests
// (smtp_test.go, using the fakeSMTP helper in smtp_fake_test.go) build a
// reusable version of this and also cover a stalled greeting, a rejected
// verb, a dropped QUIT, and STARTTLS/AUTH failures.
func TestSMTPSendsOverPlainText(t *testing.T) {
    ln, err := net.Listen("tcp", "127.0.0.1:0")
    if err != nil {
        t.Fatal(err)
    }
    defer ln.Close()

    var got struct{ from, rcpt string; data []byte }
    go func() {
        conn, err := ln.Accept()
        if err != nil {
            return
        }
        defer conn.Close()
        tc := textproto.NewConn(conn)
        tc.PrintfLine("220 fake ESMTP")
        for {
            line, err := tc.ReadLine()
            if err != nil {
                return
            }
            verb, arg, _ := strings.Cut(line, " ")
            switch strings.ToUpper(verb) {
            case "EHLO", "HELO":
                tc.PrintfLine("250 fake")
            case "MAIL":
                got.from = arg
                tc.PrintfLine("250 ok")
            case "RCPT":
                got.rcpt = arg
                tc.PrintfLine("250 ok")
            case "DATA":
                tc.PrintfLine("354 go ahead")
                // read until the lone "." line, undo dot-stuffing, store got.data
                tc.PrintfLine("250 queued")
            case "QUIT":
                tc.PrintfLine("221 bye")
                return
            }
        }
    }()

    s := &lttr.SMTP{Addr: ln.Addr().String(), Now: func() time.Time { return fixedDate }}
    m := &lttr.Message{ /* From, To, Subject, HTML, Text */ }

    if err := s.Send(context.Background(), m); err != nil {
        t.Fatal(err)
    }
    if !strings.Contains(got.from, "orders@caribhubs.com") {
        t.Errorf("MAIL FROM = %q", got.from)
    }
}

The pattern that matters: run the fake server's Accept/read loop in a goroutine, answer each verb with the SMTP status line net/smtp expects, and assert on what it recorded rather than trying to intercept a real connection. s.Addr = ln.Addr().String() points SMTP.Send at 127.0.0.1:{random port}, so the test never touches the network outside the process. To exercise STARTTLS or AUTH failures, advertise the extension in the EHLO reply (or don't) and answer (or don't) accordingly — the repo's own tests do exactly this: smtp_test.go holds the individual Test* functions (a stalled greeting, a rejected verb, a dropped QUIT, an AUTH mismatch, a failed STARTTLS handshake, and more), all built on the reusable fakeSMTP helper in smtp_fake_test.go.

Next steps

Copyright © 2026