Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

We deleted the script that generated our auth emails, because the dashboard was always the real source of truth

Nakodo sends a lot of email: outreach to creators, notifications to brands, admin notices. All of it goes through one shell in our code, with one layout, one palette and one set of helpers. Two of our emails do not, and

Nakodo sends a lot of email: outreach to creators, notifications to brands, admin notices. All of it goes through one shell in our code, with one layout, one palette and one set of helpers.

Two of our emails do not, and cannot. The sign-up confirmation and the password reset are sent by Supabase Auth, not by us. Supabase owns the token, so Supabase owns the send. Our code is not running when they go out.

For a while we handled that with a generator. pnpm auth-emails rendered the two templates from the same layout shell as every other email, wrote them into supabase/templates/, and printed the subject line for each. One source of truth, no duplicated markup, exactly what you are supposed to do.

We deleted it, and the three templates are now hand-maintained HTML files. This post is about why that was the right call, and about the one detail in those templates that is worth copying whatever you do with the rest.

The generator never removed the copy

Here is the chain the generator created:

  1. the layout shell in src/lib/email/layout.ts
  2. the generated file in supabase/templates/confirmation.html
  3. the template pasted into the Supabase dashboard, which is the one that actually sends

Step 3 is a manual paste either way. Nothing about the generator automated it, because there is no deploy step that pushes these; they live in somebody else's dashboard. So the generator did not eliminate a copy. It added one in the middle, and the copy that matters was still a human pasting HTML into a web form.

Worse, it made the chain feel automated when it was not. A change to the shared layout silently made all three artifacts disagree: code updated, file stale until someone ran the script, dashboard stale until someone remembered to paste. The failure mode of a generator whose output has to be hand-installed is that it tells you you are in sync when you are not.

Then there is what the sharing bought. These two emails cannot use anything dynamic from our code, so what they shared with the rest was appearance: a wrapper, a button, a divider, a colour. The moment the shell grows anything conditional, the auth templates have to opt out of it, and the generator's job becomes mostly rendering a reduced subset for two special cases.

And the templating languages do not match. Supabase renders these with Go's html/template. The generator had to pass {{ .TokenHash }} through our own HTML escaper untouched, which worked only because those placeholders happen to contain none of the characters it escapes. That is a true fact that nobody should have to hold in their head:

// Supabase renders them with Go's html/template, which escapes its own
// variables. The {{ … }} placeholders pass through the shell's escapeHtml
// untouched: they contain none of the characters it escapes.

Three files of plain HTML, with a table in the README saying which goes where, are honest about what this is: three artifacts that live in a dashboard, pasted by hand, changed twice a year.

File Template Subject
confirmation.html Confirm sign up Confirm your email for Nakodo
recovery.html Reset password Reset your Nakodo password
password_changed_notification.html Password changed Your Nakodo password was changed

The detail worth copying

This is the link in the reset email:

<a href="{{ if eq .RedirectTo .SiteURL }}{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery{{ else }}{{ .RedirectTo }}&token_hash={{ .TokenHash }}&type=recovery{{ end }}">
  Choose a new password
</a>

Supabase's default link is a ?code= link that exchanges a PKCE code for a session. PKCE keeps a verifier in the browser that started the flow. Which means the default password reset email only works in the browser that asked for it.

Think about when people reset passwords. They are locked out on a laptop, they go to their phone because that is where their mail is, they tap the link, and it fails. Or the mail client opens the link in its own in-app browser, which is not the browser with the verifier. That is not an edge case, it is most of the time.

So our templates send token_hash and type to our own route instead:

const { error } = code
  ? await supabase.auth.exchangeCodeForSession(code)
  : tokenHash && type
    ? await supabase.auth.verifyOtp({ token_hash: tokenHash, type })
    : { error: new Error("missing token") };

A token hash is verified server side with no browser state involved, so the link works wherever it is opened. The route still accepts ?code=, so any older link already sitting in somebody's inbox keeps working.

The Go conditional is not boilerplate

{{ if eq .RedirectTo .SiteURL }} took me a while to get right, and it is the kind of thing that works in dev and fails in production.

The app passes a redirect when it asks Supabase to send the email:

return `${await requestOrigin()}/auth/confirm?${new URLSearchParams({ next })}`;

So RedirectTo is normally https://nakodo.app/auth/confirm?next=/reset-password, and the template appends &token_hash=.... The origin comes from the request, so a sign-up on localhost confirms on localhost, and next survives the round trip.

But Supabase validates that redirect against its allow list, and when it does not accept it, it substitutes the Site URL. The Site URL is a bare origin: https://nakodo.app. Appending &token_hash= to that produces https://nakodo.app&token_hash=..., which is not a URL at all. So the two cases need two different strings, one appending with & to an existing query and one building ?token_hash= onto a bare origin.

The thing I would warn anyone about: in a correctly configured project, the else branch is the only one that ever runs. The if branch is the fallback for a misconfiguration, which is exactly when you least want the reset link to be malformed.

What the route does after that

const next = type === "recovery" ? "/reset-password" : safeNext(url.searchParams.get("next"));
const target = new URL(error ? "/login" : next, url);
if (error) {
  target.searchParams.set(
    "error",
    "That link has expired or was already used. If you've confirmed already, sign in. Otherwise sign in and we'll send a new link.",
  );
}
return NextResponse.redirect(target);

Three small decisions in there.

A recovery link ignores next entirely and goes to the new-password form. The recovery link signs you in, and the only thing anyone wants after clicking it is the password field.

next goes through safeNext, because an unvalidated next on an authentication endpoint is an open redirect, and an open redirect on the page that just created a session is the good kind of bug to find in somebody else's app.

And failure is a redirect carrying a sentence, not an error page. These links expire and get clicked twice. The login form reads ?error= and displays it, so the dead end is a page that tells you what to do next.

Two other things that cost me time

The rate limit moves when you bring your own SMTP. Turning on custom SMTP in Supabase resets the auth email rate limit to something low, 30 an hour across all users on the project. Not per user. One enthusiastic afternoon of testing and real sign-ups stop arriving. It needs raising deliberately, and the app does its own per-address and per-IP limiting instead of relying on that number for abuse protection.

No webfont survives an email client. The site uses Besley for display text. The templates ask for it and then name the fallbacks that will actually be used:

font-family: Besley, Georgia, 'Times New Roman', serif;

Plus <meta name="color-scheme" content="light"> and its supported-color-schemes twin, so clients that invert everything for dark mode leave paper-coloured email alone. Tables, inline styles, no classes. Email HTML is 2003 and arguing with that is time you do not get back.

Four of the templates Supabase offers, invite, magic link, change email and reauthentication, are not written at all, because the app never triggers them. An unstyled default that nobody can receive is better than a styled one somebody has to maintain.

Go and receive one

This is the easiest demo I have published. Sign up for Nakodo on the free plan, which takes no card, and the first thing that arrives is confirmation.html with your address in the footer line. Or if you already have an account, ask for a password reset and look at the link before you click it: you will see /auth/confirm?token_hash=, and you can prove the point of this post by pasting it into a completely different browser.

Then change your password and a third email turns up. That one carries no token and no action button: a sentence saying the password changed, the ordinary /forgot-password page to visit if it was not you, and a support address. A security notification whose main feature is a one-time link is a security notification that trains people to click one-time links.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.