Batteries

Email System

Every Grit API sends mail through internal/mail: one Mailer over a Transport, with SMTP, Resend, Mailgun, Postmark, SendGrid, SES, log and failover drivers, all on the standard library. The code that sends mail does not change when the provider does. Messages can go out now or through the background queue, templates are typed Go generated by grit generate mail, and the admin previews them as the API renders them.

Your codeinternal/mailDeliverynowRedisSendMessageTransportdeliverSendOrderShipped()typed datamail.Queue()email:send jobMailerlayout + text partWorkerretries with backoffMAIL_MAILERsmtp, resend, ses...Inboxor Mailhog in dev
Rendering and queueingThe configured driver
Send now or queue for the worker; either way the Mailer hands the message to the driver MAIL_MAILER names

Drivers and configuration

MAIL_MAILER names the driver, and mail.FromConfig builds it once at startup in main.go. Left empty, a real RESEND_API_KEY means Resend, which is how projects sent mail before there were drivers. With neither, development sends to Mailhog and falls back to the log, so a reset link is never lost, and production has no mailer and says so at startup. A driver that is named but not configured stops the API from starting rather than quietly sending somewhere else.

DriverHow it sendsKeys
smtpnet/smtp with STARTTLS or implicit TLS, MIME multipart. Unset, it is Mailhog on localhost:1025.SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_ENCRYPTION
resendResend's HTTP API.RESEND_API_KEY
mailgunMailgun's messages API, US or EU endpoint.MAILGUN_DOMAIN, MAILGUN_SECRET, MAILGUN_ENDPOINT
postmarkPostmark's email API.POSTMARK_TOKEN, POSTMARK_MESSAGE_STREAM
sendgridSendGrid's v3 Mail Send API.SENDGRID_API_KEY
sesAmazon SES API v2, signed with SigV4. No AWS SDK.AWS_SES_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
logWrites each message to the log and to storage/mail. Refused in production unless MAIL_ALLOW_LOG_IN_PRODUCTION=true.MAIL_LOG_PATH
failoverTries each driver in MAIL_FAILOVER in order, logging every failure.MAIL_FAILOVER
.env
# Common to every driver
MAIL_MAILER=smtp
MAIL_FROM=noreply@yourdomain.com
MAIL_FROM_NAME=My App
# SMTP. SMTP_ENCRYPTION is tls, starttls or none; empty means tls on
# port 465, none for localhost and starttls for anything else.
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=587
SMTP_USERNAME=apikey
SMTP_PASSWORD=your-password
# Or try one driver, then the next
# MAIL_MAILER=failover
# MAIL_FAILOVER=postmark,smtp

The HTTP drivers share one client with a 15 second timeout, retry once on a network error or a 5xx, and never retry a 4xx, which is the provider saying the message itself is wrong. /api/health reports the driver in use, and the admin's System Health page shows it.

Development mail with Mailhog

docker compose up -d starts Mailhog beside Postgres and Redis. With no driver configured, development mail goes to it over SMTP on MAILHOG_SMTP_PORT, and its inbox is on MAILHOG_UI_PORT. When Mailhog is not running the message is written to storage/mail and the log instead.

Sending now

SendMessage takes any message: several recipients, cc, bcc, reply-to, a text part, attachments and headers. Send and SendRaw keep the signatures they always had. Password resets, email verification and recovery codes are sent this way, because the person is waiting for them and a failure should show while they are there.

internal/services/invoice.go
err := mailer.SendMessage(ctx, &mail.Message{
To: []string{customer.Email},
Cc: []string{"accounts@example.com"},
ReplyTo: "billing@example.com",
Subject: "Invoice INV-001",
HTML: "<p>Your invoice is attached.</p>",
Text: "Your invoice is attached.",
Attachments: []mail.Attachment{
{Filename: "INV-001.pdf", ContentType: "application/pdf", Content: pdf},
},
})
// A built-in template with its data
err = mailer.Send(ctx, mail.SendOptions{
To: user.Email,
Subject: "Welcome",
Template: "welcome",
Data: map[string]interface{}{"AppName": cfg.AppName, "Name": user.FirstName},
})

Queueing

mail.Queue puts the whole message on the email:send background job. The request returns without waiting for the provider, and the worker sends it with the same Mailer, retrying with exponential backoff. Queue checks the message first, so a missing recipient or a header with a line break fails in the request, not in the worker later.

internal/handlers/orders.go
// svc.Jobs is the *jobs.Client main.go builds when Redis is reachable.
if err := mail.Queue(ctx, svc.Jobs, &mail.Message{
To: []string{order.Email},
Subject: "Order confirmed",
HTML: html,
Text: text,
}); err != nil {
return fmt.Errorf("queueing the confirmation: %w", err)
}

Attachments are capped at 256 KB when queued. The message waits in Redis and is stored again for every retry, so mail.Queue refuses larger attachments with mail.ErrAttachmentsTooLarge. Upload the file to storage and send a link to it instead.

Writing your own email

The generator writes one file in internal/mail/templates: a typed data struct, an html/template body inside the shared layout (the app name, the card and the footer), a plain-text alternative, and helpers to render, send and queue it. The file registers the email for the admin's Mail Preview. It is yours from then on, and a second generate refuses to overwrite it.

grit generate mail OrderShipped
internal/mail/templates/order_shipped.go (abridged)
type OrderShippedData struct {
AppName string // empty uses APP_NAME
Name string
ActionURL string
}
const OrderShippedSubject = "Order shipped"
func RenderOrderShipped(data OrderShippedData) (*mail.Message, error)
func SendOrderShipped(ctx context.Context, m *mail.Mailer, to string, data OrderShippedData) error
func QueueOrderShipped(ctx context.Context, q mail.Enqueuer, to string, data OrderShippedData) error
using it
err := templates.SendOrderShipped(ctx, svc.Mailer, order.Email, templates.OrderShippedData{
Name: order.CustomerName,
ActionURL: "https://shop.example.com/orders/" + order.ID,
})

Add fields to the struct and use them in the body; the compiler then checks every call that sends the email. The built-in templates, welcome, password-reset, email-verification and notification, stay in internal/mail/templates.go for Mailer.Send.

Previewing in the admin

/system/mail lists every registered template and shows it rendered with sample data in a sandboxed frame, with its text part beside it. The page reads two staff routes, both needing system.view:

GET /api/admin/mail/templates # name, description, subject, and the driver in use
GET /api/admin/mail/preview/:template # the rendered HTML; ?part=text for the text part

Testing with the fake

internal/mail/mailtest is a Transport that records messages instead of sending them. The generated auth tests use it for the reset and verification flows.

internal/handlers/orders_test.go
fake := mailtest.New()
h := &OrderHandler{DB: db, Mailer: fake.Mailer()}
// ... exercise the handler ...
msg := fake.AssertSent(t, "ada@example.com", "Order shipped")
if !strings.Contains(msg.Text, "/orders/") {
t.Errorf("the email has no link to the order")
}
fake.FailWith(errors.New("provider down")) // every later send fails
fake.Reset()
fake.AssertNothingSent(t)

Turning notification mail off

The notifications.email_enabled setting (Settings, Notifications) stops notification emails, such as the new-ticket email to SUPPORT_EMAIL, without a deploy. The in-app notifications still arrive. Account mail, password resets and verification, is not affected. See Background Jobs for the queue itself.