Transactional email
Resend + HTML templates.
Transactional email is plumbing every product needs. Grit uses Resend in production and Mailhog in dev ā same code path, different backend. This lesson covers sending your first templated email and the patterns you'll repeat.
Two backends, one API
Development: RESEND_API_KEY blank ā mail goes to Mailhog (localhost:8025)Production: RESEND_API_KEY set ā mail goes via ResendTest: use a mock mailer ā no network calls
Same mailer.Send() call everywhere. Grit picks the backend based on env. You never have to write if env == "dev" in your handler.
Sending an email
err := h.mailer.Send(ctx, mail.Email{To: user.Email,Subject: "Welcome to Acme",Template: "welcome",Data: map[string]any{"name": user.Name,"dashboard_url": cfg.FrontendURL + "/dashboard",},})
The mailer loads the welcome template, fills in the data, and dispatches.
HTML templates
Grit ships four templates by default:
templates/āāā welcome.html after signupāāā verify-email.html email verificationāāā password-reset.html password reset linkāāā invitation.html team invite
Go template syntax ā simple, no learning curve:
<h1>Welcome to Acme, {{.name}}!</h1><p>Your account is ready. Click below to start:</p><a href="{{.dashboard_url}}" class="btn">Go to dashboard</a>
The scaffolded templates are intentionally simple. Most teams swap in a designer's HTML once they ship.
Mailhog ā see your dev email
While the API runs locally, every mailer.Send call delivers to Mailhog. Open http://localhost:8025 and you see the inbox ā including the rendered HTML, the headers, and the raw source.
Bounces + retries
For high-volume products, hook the Resend webhook into your API:
r.POST("/api/webhooks/resend", h.resendWebhook.Handle)// Verifies HMAC signature, updates user.email_bounced_at on hard bounces// Idempotent ā duplicates are caught by an Idempotency-Key header
When a user's email hard-bounces, stop sending them mail. Grit gives you the webhook receiver; you decide the policy.
The job pattern (recommended)
Don't call mailer.Send from inside an HTTP handler. Mail is slow (~200ms), can fail, and shouldn't block the user's request. Enqueue an Asynq job (from the previous lesson) and let the worker send it.
// In the handler ā enqueue, don't sendh.jobs.EnqueueWelcomeEmail(ctx, user.ID)// In the worker ā sendfunc (j *Jobs) HandleWelcomeEmail(ctx context.Context, task *asynq.Task) error {var p WelcomeEmailPayloadjson.Unmarshal(task.Payload(), &p)user, _ := j.users.GetByID(ctx, p.UserID)return j.mailer.Send(ctx, mail.Email{To: user.Email,Subject: "Welcome!",Template: "welcome",Data: map[string]any{"name": user.Name},})}
Quick check
Try it
Send your first email through Mailhog:
- Add a temporary debug handler that calls
h.mailer.Sendwith the welcome template and your own email address. - Hit the handler.
- Open Mailhog at
http://localhost:8025. The email should be sitting there. - Click it, view the rendered HTML.
Paste a screenshot or the email subject + body in notes.md.
What's next
Email + jobs. Next ā file storage. Uploading avatars, attachments, and the S3-compatible interface that swaps between MinIO (dev), R2, and AWS S3 (prod).
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled ā suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub