Transactional email

Resend + HTML templates.

6 mineasy

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 Resend
Test: 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

apps/api/internal/handlers/auth.go (excerpt)
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:

apps/api/internal/mail/templates/
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:

apps/api/internal/mail/templates/welcome.html (excerpt)
<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.

Pro tip: Mailhog also catches mail you wouldn't want sent in dev — like the welcome email for the test admin you seed. You never email a real user by accident.

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 send
h.jobs.EnqueueWelcomeEmail(ctx, user.ID)
// In the worker — send
func (j *Jobs) HandleWelcomeEmail(ctx context.Context, task *asynq.Task) error {
var p WelcomeEmailPayload
json.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

You hit POST /api/auth/register. The user is created but the welcome-email job throws an error in the worker. What does the user see?

Try it

Send your first email through Mailhog:

  1. Add a temporary debug handler that calls h.mailer.Send with the welcome template and your own email address.
  2. Hit the handler.
  3. Open Mailhog at http://localhost:8025. The email should be sitting there.
  4. 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