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.slogescapes 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. SoConsoleSenderwrites straight toos.Stdoutwithfmt.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:
- The reset-token table + queries + a
token.gogenerator. - The
email.Senderinterface and its two implementations, wired in. - The forgot → reset flow, end to end — completed entirely from the console.