SMTP
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
}
Addris the server'shost:port, e.g."smtp.example.com:587".Authauthenticates the session (smtp.PlainAuth,smtp.CRAMMD5Auth, or a customsmtp.Auth). Leave itnilto send without authentication.Headersare added to every message thisSMTPsends — the natural place for a provider-specific header likeX-SES-CONFIGURATION-SETorX-Mailgun-Tag. They go through the same checks asMessage.SetHeader(reserved names, CR/LF/NUL, printable ASCII), applied to a copy of the message, so the same*Messagesent through two differentSMTPvalues never cross-contaminates their headers.Nowstamps theDateheader;nilmeanstime.Now. Set it in tests for deterministic output.
package main
import (
"context"
"fmt"
"net/mail"
"github.com/sulv-io/lttr"
)
func main() {
s := <tr.SMTP{
Addr: "smtp.example.com:587",
Headers: map[string]string{
"X-SES-CONFIGURATION-SET": "transactional",
},
}
m := <tr.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:
- Clone
m, adds.Headersto the copy, and serialise it withMIME— before any network connection is made, so a message that cannot be encoded never opens a socket. - Split the host out of
Addrand dial it (net.Dialer.DialContext, soctxgoverns the dial too). EHLO localhost.- STARTTLS if the server advertises the extension:
tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, wherehostis the host parsed out ofAddr— the certificate is checked against the server you asked for, not whatever the server claims to be. - AUTH, only if
s.Authis set. If it is set but the server does not offer theAUTHextension, 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. MAIL FROM:<{m.From.Address}>,RCPT TO:<{m.To.Address}>,DATA, then the serialised message.- QUIT after
DATAsucceeds. A failedQUITis 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 deferredCloseeither 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.
// 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 := <tr.SMTP{Addr: ln.Addr().String(), Now: func() time.Time { return fixedDate }}
m := <tr.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
- Router and streams for picking
SMTP(or anotherSender) per stream. - Goldens and testing for testing your own templates end to end.