// vukem make:mail Welcome (also creates resources/views/mail/welcome.html)
type Welcome struct{ User models.User }
func (m Welcome) Envelope() mail.Envelope {
return mail.Envelope{To: []string{m.User.Email}, Subject: "Welcome!", Tags: map[string]string{"kind": "welcome"}}
}
func (m Welcome) Content() mail.Content { return mail.View("mail/welcome", m) }mailer, err := app.Mailer()
err = mailer.Send(ctx, Welcome{User: u})Content#
mail.View(name, data)renders anhtml/templateview; the text part comes from<name>_textwhen it exists, otherwise from the HTML (links becometext (url)). Mail views use{{/* layout: none */}}.mail.HTML(html, text)andmail.Text(text)for literal bodies.- Attachments: implement
Attachments() []mail.Attachment; inline parts setInlineandContentIDand are referenced ascid:.... - Context: implement
EnvelopeContext(ctx) mail.Envelope(mail.ContextEnveloper) to build the envelope from the send's context, e.g. a subject in the request locale withi18n.T(ctx, key). The Mailer calls it instead ofEnvelope, which stays required as the context-free fallback.SendLaterrenders before queueing, so it uses the context of the call.
Transports#
MAIL_MAILER | Transport | Settings |
|---|---|---|
smtp | net/smtp with required STARTTLS (or implicit TLS on 465), AUTH PLAIN, multipart MIME | SMTP_HOST, SMTP_PORT (587), SMTP_USERNAME, SMTP_PASSWORD, SMTP_INSECURE (local catchers only) |
resend | Resend HTTP API: tags, reply-to, attachments, the message id as Idempotency-Key, retries on 429/5xx, typed *mail.ResendError | RESEND_API_KEY |
mailtrap | Mailtrap Email API: cc/bcc, one reply-to, attachments and inline images, the category tag as category and other tags as custom variables, retries on 429/5xx, typed *mail.MailtrapError | MAILTRAP_API_TOKEN, MAILTRAP_INBOX_ID (sandbox) |
log | logs recipients and subject (bodies at debug) | default outside production |
array | in memory | default in vukemtest |
MAIL_FROM sets the default sender. Production must set MAIL_MAILER; a missing or unknown value is an error, never silent loss of mail. vukem.WithMailTransport(t) plugs in your own mail.Transport.
Mailtrap#
Some hosts block outbound SMTP (Railway on some plans, other PaaS): smtp then fails with a dial timeout. Mailtrap's HTTP API goes over HTTPS on port 443 instead.
MAIL_MAILER=mailtrap
MAILTRAP_API_TOKEN=... # Email API token, required: a missing token stops the app at boot
MAILTRAP_INBOX_ID=1234567 # optional: deliver to this Email Sandbox inbox instead of real recipients
MAIL_FROM="App <hello@your-verified-domain.com>"- Without
MAILTRAP_INBOX_IDmail goes tohttps://send.api.mailtrap.io/api/send(transactional stream); the sender domain must be verified in Mailtrap. - With
MAILTRAP_INBOX_IDit goes tohttps://sandbox.api.mailtrap.io/api/send/{inbox}, for staging. Use a token that can access that inbox. WithAPP_ENV=productionthe app logs a warning at boot, since recipients receive nothing. - A
categorytag becomes Mailtrap'scategory; other tags are sent ascustom_variables(they show up in webhooks and logs). - Mailtrap accepts a single reply-to address; more than one is an error rather than a silently dropped address.
- The API has no idempotency key, so a 5xx answered after delivery can send twice when retried.
- Errors are
*mail.MailtrapErrorwith the HTTP status and Mailtrap'serrorslist (or itsmessagewhen the shape differs), at most 5 one-line items of 200 characters. They never carry the message body or the token but may contain addresses quoted by the API.Retryable()is true for 429 and 5xx; a 2xx answeringsuccess: falseis a non-retryable error.
Security#
- Addresses go through
net/mail; subjects, tags and content ids with line breaks are rejected (no header injection).Bccis never written as a header. - The Resend key, Mailtrap token and SMTP password are
config.Secretand never appear in logs or errors.
Previews and tests#
if !app.Config.IsProduction() {
r.Handle("/_mail/", http.StripPrefix("/_mail/", mail.Preview(mailer, map[string]mail.Mailable{"welcome": sample})))
}app := vukemtest.New(t, bootstrap.New)
// ...
if len(app.Mails().SentTo("ana@example.com")) != 1 {
t.Fatal("welcome mail not sent")
}Queued mail#
mailer.SendLater(ctx, mailable, opts...) (or app.MailLater) renders the message now and sends it from a retried queue job. The message ID stays the same across retries, so Resend's idempotency key prevents duplicates. The job payload is the rendered message encrypted with app.Crypt, so reset links and other tokens never sit in the jobs tables or queue:failed output in clear text. Queue options apply to the mail job: queue.OnQueue("mail"), queue.Retries(5). mailer.SendLaterTx(ctx, tx, mailable) (or app.MailLaterTx) queues the mail only if the transaction commits, so a rolled-back order or payment sends nothing: the database queue inserts the job in the transaction, other drivers queue it right after the commit. With QUEUE_CONNECTION=sync the mail is sent during the call (after the commit for SendLaterTx); use database so requests do not wait for the mail server (memory too, but a restart loses queued mail). serve runs the worker that sends it. Clear-text mail jobs queued before v0.24.0 fail unless QUEUE_ALLOW_LEGACY_MAIL=true (an upgrade window: drain the queue before upgrading, or set it until they are gone). Someone with write access to the jobs table can replay a sealed mail; that access already allows worse.