Mail (Resend)
Transactional + marketing email with templates you can edit.
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,
--previewmode 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
package mailimport ("bytes""context""html/template""github.com/resend/resend-go/v2")type Service struct {client *resend.Clientfrom stringtpls *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.Bufferif 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
<!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}} — 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
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 β enqueues.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.
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.
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.
mailhog:image: mailhog/mailhogports:- "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 inSend(ctx, to, "new_template.html", ...). - Swap Resend for Postmark / SES β implement the same
Sendmethod. 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_outfield on User. Check it in the service before callingclient.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
Try it
Send your first email through Grit:
- Sign up for a Resend account (free tier).
- Set
RESEND_API_KEYin your.env. For dev, use Resend's test-mode key β it doesn't actually deliver. - Trigger registration on your local API. Watch the Jobs admin page β you should see
send_welcomeappear and complete. - Check the Resend dashboard for the test email.
- 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