Mail (Resend)

Transactional + marketing email with templates you can edit.

7 mineasy

Every product sends email: welcome, password reset, receipt, notification. Grit's mail battery wraps Resend with editable HTML templates and a preview page in the admin panel. Type-safe, testable, swappable.

Why we need it

Three reasons SMTP is a bad direct dependency:

  • Deliverability β€” random SMTP servers land in spam. Resend, SendGrid, Postmark handle SPF/DKIM/DMARC for you.
  • Templates β€” building HTML email from string concat is suffering. Templates separate copy from code.
  • Preview + test β€” Mailhog locally, --preview mode in admin, no accidental sends to real customers.

Where it lives

apps/api/internal/mail/
β”œβ”€β”€ mail.go              ← Service: Send(to, template, data)
β”œβ”€β”€ templates/
β”‚   β”œβ”€β”€ welcome.html
β”‚   β”œβ”€β”€ password_reset.html
β”‚   β”œβ”€β”€ receipt.html
β”‚   └── notification.html
└── mailhog.go           ← Dev-only SMTP fallback

How it's implemented

apps/api/internal/mail/mail.go (simplified)
package mail
import (
"bytes"
"context"
"html/template"
"github.com/resend/resend-go/v2"
)
type Service struct {
client *resend.Client
from string
tpls *template.Template // pre-parsed HTML templates
}
func New(apiKey, from, tplDir string) (*Service, error) {
tpls, err := template.ParseGlob(tplDir + "/*.html")
if err != nil { return nil, err }
return &Service{
client: resend.NewClient(apiKey),
from: from,
tpls: tpls,
}, nil
}
func (s *Service) Send(ctx context.Context, to, templateName, subject string, data any) error {
var body bytes.Buffer
if err := s.tpls.ExecuteTemplate(&body, templateName, data); err != nil {
return err
}
_, err := s.client.Emails.Send(&resend.SendEmailRequest{
From: s.from,
To: []string{to},
Subject: subject,
Html: body.String(),
})
return err
}

Notice: templates are parsed ONCE at startup. Calling Send is fast β€” just execute the pre-parsed template and hand off to Resend.

A template

apps/api/internal/mail/templates/welcome.html
<!doctype html>
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 40px auto;">
<h2>Welcome to {{.AppName}}, {{.Name}}!</h2>
<p>You're all set. Here are a few things to try first:</p>
<ul>
<li><a href="{{.AppURL}}/onboarding">Take the 2-minute tour</a></li>
<li><a href="{{.AppURL}}/docs">Read the docs</a></li>
</ul>
<p>If you hit a snag, just reply to this email.</p>
<p style="color:#888;font-size:12px">{{.AppName}} &mdash; built with Grit</p>
</body>
</html>

Go's standard html/template β€” auto-escapes every variable to prevent XSS. {{.Name}} injects safely; an attacker who controls Name can't inject HTML.

How you call it from a service

apps/api/internal/services/auth_service.go
func (s *AuthService) Register(ctx context.Context, in RegisterInput) (User, string, error) {
user, token, err := s.createUser(ctx, in)
if err != nil { return User{}, "", err }
// Don't block the request waiting for email β€” enqueue
s.jobs.Enqueue("send_welcome", map[string]any{
"to": user.Email,
"name": user.Name,
})
return user, token, nil
}

Critical: email is async. The user gets their token in 50ms; the email goes through a background job. If Resend is slow or down, the user's registration still succeeds.

apps/api/internal/jobs/handlers.go
func (h *Handlers) SendWelcome(ctx context.Context, payload map[string]any) error {
return h.mail.Send(ctx, payload["to"].(string), "welcome.html", "Welcome!", map[string]any{
"AppName": "Acme",
"Name": payload["name"].(string),
"AppURL": os.Getenv("APP_URL"),
})
}

The Mail Preview admin page

Grit's admin panel includes /admin/system/mail β€” a page that renders every template with sample data so you can iterate copy / styles without sending. Add a new template, it shows up automatically; edit copy, refresh, see the result.

Implementation: a Go endpoint reads template files, executes them with seed data, returns the HTML. The admin page embeds it in an iframe.

Always re-add the unsubscribe link. Even for transactional email, if a recurring trigger fires it (digest emails), include a one-click unsubscribe. CAN-SPAM and GDPR both require it; Resend will flag domains without it.

Local dev β€” Mailhog

For local dev, Grit defaults to Mailhog β€” an in-memory SMTP server with a web UI at localhost:8025. Sending an email shows it in the Mailhog inbox; nothing leaves your machine.

docker-compose.yml (excerpt)
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UI

When RESEND_API_KEY is empty, the mail service routes through Mailhog automatically. Set the key for staging to test real deliverability.

How to modify this battery

  • Add a new template β€” drop an HTML file in mail/templates/. It's auto-loaded on startup. Reference it by filename in Send(ctx, to, "new_template.html", ...).
  • Swap Resend for Postmark / SES β€” implement the same Send method. The service interface is tiny β€” one function.
  • Add Markdown templates β€” parse .md + Go template, render through Markdown lib, send as HTML. Useful for content-heavy emails.
  • Per-user opt-out β€” add an email_opt_out field on User. Check it in the service before calling client.Send.

Marketing email β€” separate from transactional

Transactional (welcome, receipt, password reset) is what we just covered. Marketing (newsletter, drip, broadcast) is a different beast:

  • Higher volume β€” needs batching.
  • List management β€” segments, opt-ins.
  • Open / click tracking β€” pixel + tracked links.

Grit's admin panel has a marketing email module that uses the same Resend backbone but adds list management. Out of scope for this lesson; covered in the SaaS-with-AI course.

Quick check

Why is the welcome email enqueued as a background job instead of sent inline during the register handler?

Try it

Send your first email through Grit:

  1. Sign up for a Resend account (free tier).
  2. Set RESEND_API_KEY in your .env. For dev, use Resend's test-mode key β€” it doesn't actually deliver.
  3. Trigger registration on your local API. Watch the Jobs admin page β€” you should see send_welcome appear and complete.
  4. Check the Resend dashboard for the test email.
  5. Now create a NEW template β€” "Account suspended" or "Password changed" β€” and call it from a handler.

What's next

Next lesson β€” Background jobs (asynq). The engine that runs the "send welcome" you just queued. Retries, cron, dashboards, all baked in.

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