Theory

The email interface and reset design

Auth has one gap left: a user who forgets their password is locked out forever. This lesson builds password reset — and to send the reset link, an email layer you'll reuse for signer invites later.

Email behind an interface. Sending real email needs an API key and a paid service — a terrible barrier for someone learning. So email sits behind a one-method interface:

type Sender interface {
	Send(ctx context.Context, msg Message) error
}

Two implementations, chosen by config:

  • ConsoleSender (the dev default) doesn't send anything — it prints the email, link and all, to your terminal. You run the entire reset flow, copy the link from the log, and finish — with no API key, no account, nothing.
  • ResendSender (prod) POSTs to the Resend API. One HTTP call, no SDK.

email.New picks: anything other than "resend" gives you the console sender. Dev is zero-config; prod is one env var. It's the same "an interface with a dev impl and a prod impl" seam as storage.Store later — "in production this is a real provider; here it prints to your terminal."

A small bug with a real consequence — why the console sender writes to stdout directly. The obvious way to print the email is log.Info(...). Don't. slog escapes newlines into literal \n — so a multi-line email with a reset link collapses into one unreadable line of ...\nhttp://...\n.... A student who can't read the link can't finish the lesson. So ConsoleSender writes straight to os.Stdout with fmt.Fprintf, drawing a clean box with real line breaks. Tiny detail; without it, the whole flow is unusable.

The reset token — the sessions pattern again. A reset link carries a random token; the database stores only its SHA-256, single-use (used_at), and expiring (1 hour). A leaked backup yields no working links. And completing a reset does three things in a row: mark the token used, invalidate every other outstanding reset token, and delete every session for that user. A password change should log you out everywhere — if someone reset your password, any session they'd opened dies with it.

Enumeration resistance, again. "Forgot password" shows the same confirmation whether or not the email exists"if an account exists, we've sent a link." No probing the form to learn who has an account.

This lesson:

  1. The reset-token table + queries + a token.go generator.
  2. The email.Sender interface and its two implementations, wired in.
  3. The forgot → reset flow, end to end — completed entirely from the console.