DEV Community

Cover image for Branded Cognito emails: the customMessage trigger in 20 minutes
Ibukun Demehin
Ibukun Demehin

Posted on Originally published at ibukundemehin.com

Branded Cognito emails: the customMessage trigger in 20 minutes

Cognito's default emails are a small embarrassment: plain text, no name, a bare code — the kind of message a security-aware user treats as a phishing test. For a shopping app trying to look like something you'd trust with your receipts, that's the first impression at sign-up.

The fix is a single Lambda on the user pool's customMessage trigger. The wiring is twenty minutes. The interesting parts are the three ways it silently fails, and the one line that keeps the deploy from tying itself in a knot.

TL;DR — Define a customMessage trigger function with resourceGroupName: "auth" so it deploys inside the auth stack — a trigger living in the shared function stack is an auth ↔ function circular dependency. In the handler, the code parameter must appear in the body or Cognito rejects the message and sends nothing. Write email HTML like it's 2009 — tables, inline styles, no webfonts (your brand font can't ride along; the colours carry the brand). And on the invite trigger, codeParameter isn't a code, it's the temporary password, and the username parameter must appear too.

(Part 28 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The one line that matters in the definition

// resourceGroupName keeps it in the auth stack — a trigger living in the
// shared function stack would be an auth↔function circular dependency.
export const customMessage = defineFunction({
  name: "custom-message",
  entry: "./handler.ts",
  resourceGroupName: "auth",
});
Enter fullscreen mode Exit fullscreen mode

By default, functions deploy into a shared function stack. But a Cognito trigger is referenced by the user pool (auth → function) while also needing the pool's context — and in a backend where the function stack already depends on auth for other reasons, that reference points the dependency arrow both ways. resourceGroupName: "auth" places the Lambda inside the auth stack, so the pool and its trigger are siblings and no cross-stack arrow exists. Same lesson as Part 25's stream wiring and Part 14's IAM policy: in a multi-stack backend, where a resource is declared is a dependency-direction decision.

Three ways it silently sends nothing

The handler's own docstring is the checklist:

/**
 * Branded Cognito emails — verification + password reset. Cognito injects the
 * code as `event.request.codeParameter`; the body MUST contain it or Cognito
 * rejects the message and nothing is sent.
 *
 * HTML email rules apply: tables + inline styles only (Outlook), no webfonts —
 * Nunito can't ride along, so the system stack stands in and the brand is
 * carried by the colours.
 *
 * AdminCreateUser IS handled … Careful: on that trigger `codeParameter` is
 * the TEMPORARY PASSWORD, not a code, and `usernameParameter` is the email to
 * sign in with. Both must appear in the body or Cognito refuses to send.
 */
Enter fullscreen mode Exit fullscreen mode

1. The code placeholder must be in the body. Cognito hands you event.request.codeParameter — a placeholder token it substitutes after your handler returns. Build a beautiful template that forgets to include it, and Cognito doesn't send an ugly email; it sends no email, with no error surfaced to the user. Test every trigger type by actually receiving the message.

2. Email HTML is not web HTML. Tables for layout, every style inline, no external stylesheets — Outlook still lives in the past. And no webfonts: the app's brand font can't come along, so the template uses the system font stack and lets the colours — the teal header, the mint code panel — carry the brand identity. A responsive @media block in a <style> tag is the one modern concession most clients honour.

3. The invite trigger lies about its parameter names. The same handler serves AdminCreateUser (the admin console's "Invite an admin"). On that trigger, codeParameter is not a code — it's the temporary password — and usernameParameter is the email to sign in with. Both must appear in the body, or Cognito refuses to send. The template branches on triggerSource and uses different copy and different placeholders for the invite.

What "branded" means here

One template shell: a teal header with the wordmark, a mint panel with the six-digit code in large spaced digits (30px, 6px letter-spacing on mobile — a code you can read from across the room), a short line of what to do, and a muted footer. The verification and reset emails share the shell and differ in copy; the invite reuses it with the temporary-password panel. Every colour is the design system's email-safe subset — six hex values copied from the shared tokens file, never a gradient (Outlook again).

The template later grew a second dimension — every one of these emails now arrives in the user's own language, flipped to right-to-left for Arabic — but that's a later part's story, because it came with a war story about Cognito's frozen attribute schema.

What I took away

  • resourceGroupName: "auth" — the trigger belongs beside the pool, not in the function stack.
  • Include the placeholder or nothing sends. Cognito fails closed and quiet; verify by receiving.
  • Write email HTML for Outlook — tables, inline styles, system fonts, colours as the brand.
  • Invite trigger: codeParameter is the temporary password, and the username must appear too.
  • Test all three trigger types (sign-up, forgot-password, admin invite) with a real inbox before calling it done.

Next up

Part 29 goes back to receipts and fixes the thing that made scanning feel like homework: scan now, link later — the receipt is saved before the app asks which list it belongs to.

What's the worst default email your platform sends on your behalf — and have you checked it recently?

Top comments (0)