Receitas
Uma interface que os handlers chamam, SMTP atrás dela em produção, o log em dev, um corpo vindo de template e cabeçalhos que se recusam a ser injetados.
Mandar e-mail são três problemas vestindo um casaco só: falar com um servidor, montar uma mensagem válida e não mandar nada a partir de um teste. Só o primeiro é sobre SMTP.
A costura#
// 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
}Uma interface com um método, definida onde é usada. Os handlers nunca ficam sabendo qual implementação receberam, e é esse o ponto inteiro: um teste que cadastra um usuário não pode mandar e-mail para um endereço de verdade.
// 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 guarda o mailer sob Mailer, a interface, e não sob a struct que por acaso está atrás dela hoje — é para isso que serve o argumento de tipo. Um handler que pede Mailer recebe o log em dev e o SMTP em produção, e nunca fica sabendo da diferença.
Produção sem endereço configurado se recusa a subir. Isso é de propósito: um cadastro que silenciosamente não manda nada é descoberto por um cliente, e um processo que não sobe é descoberto pelo deploy.
Enviando#
// 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 não recebe contexto, e um servidor de e-mail que para de responder seguraria a requisição até o timeout de escrita. O select devolve o prazo ao handler; a goroutine termina sozinha.
A mensagem#
// 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
}O laço lá em cima é a única checagem de segurança deste arquivo e a que costuma faltar. Uma quebra de linha dentro de um cabeçalho é como um campo "nome" de formulário vira um segundo Bcc: — seu servidor, a lista de outra pessoa. Recusar é o certo; escapar é um chute.
O corpo vem de text/template, não de 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())
}Escape de HTML num e-mail de texto puro transforma um apóstrofo em ' na caixa de entrada de alguém. Se você manda um e-mail multipart com HTML, aí o html/template é o certo para aquela parte — e a parte de texto vai junto, porque muito cliente mostra ela.
Em 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 }
A mensagem inteira no log, link incluído, que é o que você de fato precisa quando está testando uma redefinição de senha pela quinta vez.