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.
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.
| Driver | How it sends | Keys |
|---|---|---|
| smtp | net/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 |
| resend | Resend's HTTP API. | RESEND_API_KEY |
| mailgun | Mailgun's messages API, US or EU endpoint. | MAILGUN_DOMAIN, MAILGUN_SECRET, MAILGUN_ENDPOINT |
| postmark | Postmark's email API. | POSTMARK_TOKEN, POSTMARK_MESSAGE_STREAM |
| sendgrid | SendGrid's v3 Mail Send API. | SENDGRID_API_KEY |
| ses | Amazon SES API v2, signed with SigV4. No AWS SDK. | AWS_SES_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
| log | Writes each message to the log and to storage/mail. Refused in production unless MAIL_ALLOW_LOG_IN_PRODUCTION=true. | MAIL_LOG_PATH |
| failover | Tries each driver in MAIL_FAILOVER in order, logging every failure. | MAIL_FAILOVER |
# Common to every driverMAIL_MAILER=smtpMAIL_FROM=noreply@yourdomain.comMAIL_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.comSMTP_PORT=587SMTP_USERNAME=apikeySMTP_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.
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 dataerr = 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.
// 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
type OrderShippedData struct {AppName string // empty uses APP_NAMEName stringActionURL string}const OrderShippedSubject = "Order shipped"func RenderOrderShipped(data OrderShippedData) (*mail.Message, error)func SendOrderShipped(ctx context.Context, m *mail.Mailer, to string, data OrderShippedData) errorfunc QueueOrderShipped(ctx context.Context, q mail.Enqueuer, to string, data OrderShippedData) error
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 useGET /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.
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 failsfake.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.
