Cookbook
One interface the handlers call, SMTP behind it in production, the log in dev, a body from a template, and headers that refuse to be injected.
Sending mail is three problems wearing one coat: talking to a server, assembling a message that is valid, and not sending anything from a test. Only the first is about SMTP.
The seam#
// Mailer is what the handlers call. They never learn which one they got,
// which is the whole point: the test and the dev server do not send mail.
type Mailer interface {
Send(ctx context.Context, to []string, subject, body string) error
}An interface with one method, defined where it is used. The handlers never learn which implementation they got, which is the entire point: a test that signs a user up must not send mail to a real address.
// SetupMailer makes the choice once, at startup. Production without an
// address configured fails to start, which is better than a sign-up that
// silently sends nothing.
func SetupMailer(a *trilha.App) error {
if a.Env() == trilha.Dev {
trilha.Provide[Mailer](a, LogMailer{Log: a.Logger()})
return nil
}
addr, from := os.Getenv("SMTP_ADDR"), os.Getenv("SMTP_FROM")
if addr == "" || from == "" {
return errors.New("SMTP_ADDR and SMTP_FROM are required outside dev")
}
host, _, _ := strings.Cut(addr, ":")
trilha.Provide[Mailer](a, SMTPMailer{
Addr: addr,
From: from,
Auth: smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASSWORD"), host),
})
return nil
}// SendWelcome is the other end of the seam: a handler asks for the interface,
// never for the implementation behind it. The type argument is what Provide
// filed the value under, which is why it is written out here — LogMailer and
// SMTPMailer are two answers to the same question.
func SendWelcome(c *trilha.Ctx, name, email, link string) error {
return Welcome(c.Context(), trilha.Use[Mailer](c), name, email, link)
}Provide files the mailer under Mailer, the interface, and not under the struct that happens to be behind it today — that is what the type argument is for. A handler that asks for Mailer gets the log in dev and SMTP in production, and never learns the difference.
Production without an address configured refuses to start. That is deliberate: a sign-up that silently sends nothing is discovered by a customer, and a process that will not boot is discovered by the deploy.
Sending#
// SMTPMailer sends through a real server.
type SMTPMailer struct {
Addr string // "smtp.example.com:587"
From string
Auth smtp.Auth
}// Send hands the message to the server. smtp.SendMail takes no context, so
// the deadline is honoured here: when the request gives up, the handler
// returns and the goroutine finishes on its own.
func (m SMTPMailer) Send(ctx context.Context, to []string, subject, body string) error {
msg, err := Message(m.From, to, subject, body)
if err != nil {
return err
}
done := make(chan error, 1)
go func() { done <- smtp.SendMail(m.Addr, m.Auth, m.From, to, msg) }()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}smtp.SendMail takes no context, and a mail server that stops answering would otherwise hold the request until the write timeout. The select gives the deadline back to the handler; the goroutine finishes on its own.
The message#
// Message assembles the bytes of RFC 5322. A newline inside a header is how
// a form field becomes a second Bcc:, so anything that came from outside is
// refused rather than escaped.
func Message(from string, to []string, subject, body string) ([]byte, error) {
for _, v := range append([]string{from, subject}, to...) {
if strings.ContainsAny(v, "\r\n") {
return nil, errors.New("cookbook: header injection")
}
}
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
return []byte(b.String()), nil
}The loop at the top is the only security check in this file and the one that is usually missing. A newline inside a header is how a "name" field from a form becomes a second Bcc: — your server, someone else's mailing list. Refusing is right; escaping is a guess.
The body comes from text/template, not html/template:
// welcome is text/template, not html/template: what is being escaped here
// is nothing, and HTML escaping in a plain-text mail turns an apostrophe
// into '.
var welcome = template.Must(template.New("welcome").Parse(
`Hello, {{.Name}}.
Your account is ready. Set your password here:
{{.URL}}
This link is good for one hour.
`))// Welcome renders the body and sends it.
func Welcome(ctx context.Context, m Mailer, name, email, link string) error {
var b strings.Builder
if err := welcome.Execute(&b, struct{ Name, URL string }{name, link}); err != nil {
return err
}
return m.Send(ctx, []string{email}, "Welcome", b.String())
}HTML escaping in a plain-text mail turns an apostrophe into ' in somebody's inbox. If you send a multipart HTML mail, then html/template is right for that part — and the plain one still goes along, because a lot of clients show it.
In dev#
// LogMailer is the implementation for dev and tests: it writes the message
// to the log. Nobody's inbox learns about your fixtures.
type LogMailer struct{ Log *slog.Logger }
The whole message in the log, including the link, which is what you actually need when you are testing a password reset for the fifth time.