Blazalek.com

05 / 10

Transactional Email Design

How to build the mail users expect, clear, fast and action-oriented: subject lines, structure, mobile-first layout, OTP/CTA display and error handling.

By

Key takeaways

  • Users act on transactional mail in seconds, lead with a clear subject, one obvious action, and a mobile-first layout.
  • Show OTPs and primary CTAs prominently and make them easy to copy or tap.
  • Handle error and edge cases (expired links, retries) directly in the copy.
On this page

This chapter answers how to design and write transactional emails so they are clear, fast, trustworthy, and effortless to act on. Transactional emails are the messages users expect and need: password resets, sign-in codes, order confirmations, shipping updates, receipts, email verification links, and security alerts. They are triggered by a specific action the user (or the system on the user's behalf) took, and they almost always carry time-sensitive, action-oriented information.

Quick answers to the questions covered here:

  • What makes a transactional email "good"? Clarity, one action, sent in seconds.
  • How should I write the subject line? Specific, names the account or order, no secrets.
  • What goes above the fold? Purpose, the action button or code, and the expiration.
  • How big should buttons and codes be? Buttons 44x44px minimum, codes 24-32px monospace.
  • What address should it come from? A consistent From Name on a sending subdomain, with a real monitored Reply-To, never noreply@.
  • What happens when the link expires or the email never arrives? Show a clear message and offer a one-click resend.

Scope: this chapter is about the design and content of transactional emails. For which transactional emails each type of app needs, see Transactional Email Catalog. For making sure they actually arrive, see Deliverability and Sending Reliability.

Provider neutrality. Everything below is written against a generic emailClient (or provider) abstraction and against open standards, SMTP headers, Node's built-in crypto, the Standard Webhooks signing scheme, and the relevant RFCs. Any ESP that exposes a "send raw message with custom headers" API can implement every pattern here. Provider names (Amazon SES, Postmark, SendGrid, Mailgun, Resend, Brevo, SparkPost, Mandrill, and others) appear only as illustrative examples, never as an assumed default.


Why do transactional emails matter so much?

Because they sit on the critical path of the user's task, and because users are actually waiting for them. Transactional emails enjoy the highest open and click rates of any email category, frequently several times higher than marketing email. That attention is a responsibility. A confusing, slow, or untrustworthy transactional email directly blocks the user from completing a task such as logging in, paying, or confirming an account.

Unlike marketing email, where a poor result is a missed opportunity, a poor transactional email is an active failure: the user cannot log in, cannot confirm their order, cannot trust your security alert. Optimize for the worried, distracted, mobile user reading on a train.

The cost model: why latency and clarity are dollar figures

It helps to make the stakes concrete. Each of these failure modes maps to a measurable business cost:

FailureImmediate effectDownstream cost
Reset email arrives in 90s instead of 2sUser requests a second resetSupport ticket, duplicate sends, reputation noise
OTP code unreadable (0 vs O)Login fails, user retriesLockout after N attempts, abandoned session
Button below the fold on mobileUser scrolls, hesitates, or quitsConversion drop on verification / checkout
Receipt looks like phishingUser ignores or reports itSpam complaint (counts against the <0.3% threshold)
No "I didn't request this" pathVictim of a reset attack has no recourseAccount takeover, security incident

The last row is the one that turns a design choice into a security control. Transactional email is the channel through which account-recovery and step-up authentication actually happen, so its quality is part of your security posture, not just your UX.

Where transactional ends and marketing begins

The distinction is legal as well as practical: transactional email carries a narrow exemption from some consent and unsubscribe requirements only when its primary purpose is genuinely transactional. The moment you bolt a promotional cross-sell onto a receipt, the whole message can be reclassified as commercial in some jurisdictions, dragging it under the full marketing regime. Keep these separate. The boundary, and what it means for List-Unsubscribe and consent, is covered in Email Types and Compliance.


What are the core principles for designing transactional emails?

Three principles drive every other decision in this chapter. Get these right and most of the detailed rules follow naturally.

  1. Clarity over creativity. Users need to understand and act quickly. A transactional email is a tool, not a brand showcase. Every element that does not help the user complete their task is a distraction. When in doubt, remove it.
  2. Action-oriented. Each email should have one clear purpose and one obvious primary action. The reader should be able to answer "What is this?" and "What do I do now?" within two seconds of opening.
  3. Time-sensitive. Send immediately, within seconds of the triggering event. A password reset that arrives in 5 minutes is effectively broken: the user has already requested a second one, or given up. Latency is a feature here, not an afterthought.

A fourth principle for production systems: idempotency

Once you operate at scale, a fourth principle joins the three above: the same trigger must not produce two emails. Retries, at-least-once message queues, double-clicked buttons, and webhook redeliveries all conspire to fire the same send twice. A user who receives two different OTP codes does not know which one is live. The fix is an idempotency key derived from the triggering event, deduplicated at the send boundary. The full mechanism, keys, dedup windows, and retry semantics, lives in Sending Reliability; this chapter assumes you have it and shows how to wire it into the send call.

Quick principles checklist

  • One email = one purpose = one primary action
  • The purpose is obvious from the subject line alone
  • The primary action is reachable without scrolling on mobile
  • Sent within seconds of the trigger
  • Nothing in the email exists purely for branding or marketing
  • The same trigger cannot send the same email twice (idempotency key)

Good

Reset your password for Acme

Acme · support@mail.acme.com

Tap to set a new password. Link valid for 1 hour.

Reset password
  • Subject says exactly what it is and which account
  • One large action button above the fold
  • Code as selectable text, with an expiry
  • A real, monitored sender address
Bad

Action required: code 483921

noreply@acme-mailer-7.net

To continue, click the link below.

click here
  • Vague subject, indistinguishable from phishing
  • Code exposed in the subject and preview
  • Tiny “click here” link, easy to miss
  • A noreply@ address you can't reply to

How should I write the subject line?

Make it specific and include context. The subject line is the single most important line of the email. For transactional mail it has one job: tell the user exactly what this is so they can decide to open it instantly.

GoodBad
Reset your password for [App]Action required
Your order #12345 has shippedUpdate on your order
Your 2FA code for [App]Security code: 12345
Verify your email for [App]Verify your email

Notice the pattern in the "Good" column: each subject names what is happening and which account or order it relates to. The "Bad" column is either vague ("Action required", "Update on your order") or it leaks sensitive data ("Security code: 12345" exposes the code in notification previews, covered in the security question below).

Include identifiers when they help: order numbers, account names, expiration times. These let the user disambiguate at a glance, which is especially important when they have several orders in flight or multiple accounts with you.

Why does specificity win?

  • Findability: users search their inbox for "order 12345" or "[App] password". Specific subjects are retrievable later.
  • Trust: a vague subject ("Action required") is indistinguishable from phishing. A specific one ("Reset your password for [App]") signals legitimacy.
  • Speed: the user decides whether to open based on the subject. A clear subject means they act faster.

A reusable subject-line formula

Most transactional subjects fit one of two templates. Build them programmatically so they stay consistent across every template you ship:

[Verb the action] + [for/of] + [account or order identifier]
→ "Reset your password for Acme"
→ "Your order #12345 has shipped"

[Noun the artifact] + [identifier] + [optional status]
→ "Receipt for order #12345"
→ "Your Acme sign-in code"
Code for your engineers (TypeScript)
// A small, provider-neutral helper keeps subjects uniform.
function transactionalSubject(opts: {
  action: string;          // "Reset your password", "Your order ... has shipped"
  app: string;             // "Acme"
  identifier?: string;     // order number, account email, etc.
}): string {
  const base = opts.identifier
    ? `${opts.action} for ${opts.app} (${opts.identifier})`
    : `${opts.action} for ${opts.app}`;
  // Keep the meaningful words within the mobile truncation window.
  return base;
}

Can I put the OTP code in the subject line?

No. Never put OTP or 2FA codes in the subject line or preview text. Lock-screen and notification previews would expose the code to anyone who can see the phone, and it discourages opens (the user feels they got what they needed without opening, then later cannot find it). Put the code inside the email body instead. This is reinforced in the email-capture flow, see Email Capture.

The same rule applies to any single-use token, magic-link URL, partial card number, or recovery phrase. Treat the subject and pre-header as fully public, they are rendered on lock screens, smartwatches, voice assistants, and shared notification mirrors. If a value would be dangerous on a stranger's glance, it does not belong there.

How long can the subject line be?

Front-load the important words, because mobile truncates around 35 to 40 characters. Anything past that point is invisible on a phone, so the meaning must survive the cut.

Rough truncation budgets observed across common clients (treat as guidance, not contracts, they shift with device, font size, and orientation):

ContextVisible characters (approx.)
iOS Mail, portrait phone35–40
Gmail app, portrait phone35–45
Apple Watch / notification20–30
Desktop webmail (Gmail, Outlook web)60–70

Design for the narrowest realistic surface (the watch/notification line), then let the desktop have the slack.

Subject line checklist

  • States what the email is (reset, shipment, code, verification)
  • Names the account, app, or order where relevant
  • Contains no secrets (codes, tokens, partial passwords)
  • Reads as legitimate, not like a phishing "Action required"
  • Front-loads the important words (mobile truncates ~35-40 chars)
  • Avoids emoji and ALL-CAPS that trip spam heuristics on a transactional stream

What is the pre-header and how do I use it?

The pre-header (also called preview text) is the snippet of text shown after the subject line in most inbox clients. It is prime real estate that is far too often wasted on "View this email in your browser" or, worse, left blank so the client pulls in raw HTML.

Use it to do one of these:

  • Reinforce the subject: for example "This link expires in 1 hour".
  • Add urgency or context: give the user a reason to open now.
  • Preview the call to action: hint at what they will do inside.

Keep it under 90 characters. Most clients truncate around there, and mobile shows even less, so front-load the meaningful words.

Tip: if you do not set a pre-header, email clients auto-generate one from the first text in the email, often an "alt" text, a hidden link, or "If you can't see this email...". Always set it explicitly so you control the first impression.

What does a good pre-header look like?

SubjectGood pre-header
Reset your password for [App]This link expires in 1 hour. If you didn't request it, ignore this email.
Your order #12345 has shippedArriving Tue, Jul 7. Track your package inside.
Verify your email for [App]One click to activate your account. Link valid for 24 hours.

How do I implement the pre-header correctly?

The pre-header is a hidden block at the very top of the HTML body, followed by enough whitespace to stop the client from pulling later content into the preview. The standard pattern uses a visually hidden span plus a run of zero-width / non-breaking characters as a "spacer":

HTML template code
<body>
  <!-- Pre-header: shown in the inbox preview, hidden in the rendered email -->
  <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
    This link expires in 1 hour. If you didn't request it, ignore this email.
  </div>
  <!-- Spacer prevents the client from leaking body text into the preview -->
  <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">
    &#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;
  </div>
  <!-- real content follows -->
</body>

mso-hide:all handles Outlook; display:none;max-height:0;overflow:hidden handles the rest. Note that some clients (notably older Outlook) ignore display:none, which is why the visible content immediately after must also stand on its own.

Pre-header checklist

  • Set explicitly on every template (never left to auto-generation)
  • Under ~90 characters, meaningful words first
  • Contains no secrets (same rule as the subject)
  • Hidden block plus whitespace spacer so later body text doesn't leak in
  • Verified in a real preview, not just the source

How should I structure the body of the email?

Make it scannable in seconds. Most users will not read a transactional email, they will glance at it, find the button or code, and act.

Above the fold (the first screen, before any scrolling):

  • Clear purpose: what this email is, in one short line.
  • Primary action button (or the code, for OTPs).
  • Time-sensitive details such as expiration.

If a user has to scroll to find the reset button, the design has failed. On mobile, "above the fold" is roughly the top 480 to 570px.

Hierarchy: Header, then Primary message, then Details, then Action button, then Secondary info.

This ordering moves from "what is this" to "do the thing" to "everything else". Secondary info (support links, "didn't request this", legal footer) goes last because it serves the minority of readers who need it.

Format for scannability:

  • Short paragraphs, 2 to 3 sentences maximum.
  • Bullet points for lists of details (order contents, what happens next).
  • Bold for the few words that matter most.
  • Generous white space. Crowded emails feel like spam and are harder to act on.
  1. Logo or wordmark: small, for instant recognition and trust.
  2. One-line purpose: "Here's the link to reset your [App] password."
  3. Primary action: the button or the code, large and central.
  4. Expiration or time-sensitive detail: "This link expires in 1 hour."
  5. Plain-text fallback: the raw URL, in case the button doesn't render.
  6. Secondary info: "Didn't request this?", support contact.
  7. Footer: sender address, links. (Transactional email does not require a marketing unsubscribe link, see Compliance.)

Should I send multipart (HTML + plain text)?

Yes, always send multipart/alternative with both an HTML part and a genuine plain-text part. This is not optional polish:

  • Deliverability. An HTML-only message with no text part is a mild spam signal and renders poorly in text-first clients and accessibility tools.
  • Robustness. If the HTML fails to render (corporate gateways, minimalist clients, the Apple Watch), the plain-text part is what the user sees. It must contain the code or the link in usable form.
  • Screen readers and watches often prefer the text part.

The plain-text part is not a stripped HTML dump, write it deliberately:

Show example
Reset your Acme password

We received a request to reset the password for your Acme account.
Use the link below within 1 hour:

https://app.example.com/reset?token=8f3c...­d21

If you didn't request this, you can ignore this email, your
password will not change. To secure your account, visit
https://example.com/security/contact

Acme, 123 Example St, City, Country

Most provider SDKs and SMTP libraries accept both html and text fields and assemble the MIME structure for you. If you build the MIME message yourself, the structure is multipart/alternative with the text/plain part first and text/html second (clients render the last part they understand).

A minimal, accessible HTML skeleton

Email HTML is its own dialect: tables for layout, inline styles, no external CSS, no flexbox/grid you can rely on. A robust transactional skeleton looks like this:

HTML template code
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="color-scheme" content="light dark" />
  <meta name="supported-color-schemes" content="light dark" />
  <title>Reset your Acme password</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;">
  <!-- pre-header block here (see above) -->
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
    <tr>
      <td align="center" style="padding:24px;">
        <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"
               style="max-width:600px;width:100%;background:#ffffff;border-radius:8px;">
          <tr><td style="padding:32px;">
            <img src="https://cdn.example.com/logo.png" width="120" height="32"
                 alt="Acme" style="display:block;" />
            <h1 style="font-size:22px;line-height:1.3;margin:24px 0 8px;color:#111827;">
              Reset your password
            </h1>
            <p style="font-size:16px;line-height:1.5;color:#374151;margin:0 0 24px;">
              We received a request to reset your Acme password. This link
              expires in <strong>1 hour</strong>.
            </p>
            <!-- bulletproof button (see OTP/links section) -->
            <p style="font-size:14px;line-height:1.5;color:#6b7280;margin:24px 0 0;">
              Didn't request this? You can safely ignore this email, or
              <a href="https://example.com/security/contact">secure your account</a>.
            </p>
          </td></tr>
        </table>
      </td>
    </tr>
  </table>
</body>
</html>

role="presentation" on layout tables tells screen readers to ignore the table structure and read the content linearly. The lang attribute and a real <title> improve assistive-technology output and are easy wins.

Content structure checklist

  • Purpose visible in the first line of the body
  • Primary action above the fold on mobile
  • Expiration shown next to the action
  • Paragraphs of 3 sentences or fewer, plenty of white space
  • Secondary and legal info at the bottom, not competing with the action
  • multipart/alternative with a hand-written plain-text part
  • Layout tables marked role="presentation", lang and <title> set

Why does mobile-first design matter, and how do I do it?

Because more than 60% of emails are opened on mobile. Design for the phone first and let the desktop experience scale up, not the other way around. A layout that looks great on a 1280px screen but cramps a 375px phone fails the majority of your users.

  • Layout: single column, stack everything vertically. Multi-column layouts break on narrow screens.
  • Buttons: 44x44px minimum tap target, full-width on mobile. The 44px figure comes from touch-target accessibility guidance, and smaller targets cause mis-taps.
  • Text: 16px minimum for body text (smaller text triggers zoom and is hard to read), 20 to 24px for headings.
  • OTP codes: 24 to 32px in a monospace font, so each character is unambiguous and easy to read or copy.

The user reading a 2FA code is often on the phone they are logging in with. They may be switching between your app and the email app. Friction here, such as tiny buttons, text that needs zooming, or codes that are hard to read, directly causes failed logins and support tickets.

What else should I check on mobile?

  • Test in dark mode. Many clients invert or recolor emails, so make sure your logo, buttons, and code blocks stay legible. Avoid pure-white logos on transparent backgrounds.
  • Don't rely on images. Many clients block images by default. The email must be fully usable with images off, so never put the button or code inside an image.
  • Keep total width at or below 600px. This is the de facto standard that renders well across clients.

How do I make dark mode reliable?

Dark mode in email is not one behavior but three, and you cannot fully control which one a client applies:

  1. No transform (Apple Mail with a light-only email), your colors render as written.
  2. Partial / "color adjustment" (iOS, Apple Mail), the client respects color-scheme and your dark-mode CSS but may tweak near-white/near-black backgrounds.
  3. Full inversion (some Gmail and Outlook configurations), the client forcibly inverts your palette, which can swap your carefully chosen colors for muddy opposites.

Defensive measures:

HTML template code
<head>
  <meta name="color-scheme" content="light dark" />
  <meta name="supported-color-schemes" content="light dark" />
  <style>
    @media (prefers-color-scheme: dark) {
      .body-bg   { background:#0b0b0c !important; }
      .card      { background:#161618 !important; }
      .text      { color:#e5e7eb !important; }
      .code-box  { background:#1f2937 !important; color:#ffffff !important; }
    }
  </style>
</head>
  • Use a logo with a transparent halo or an explicit background swatch so it survives on both light and dark cards; pure-white-on-transparent logos vanish on white when inverted.
  • Give OTP code blocks an explicit background and border so they keep contrast under inversion.
  • Buttons: set the background via the bulletproof VML/<a> pattern (next section) so a forced inversion does not turn your brand color into something illegible.

Mobile checklist

  • Single-column layout
  • Buttons of 44x44px or larger and full-width on small screens
  • Body text of 16px or larger, headings 20 to 24px
  • OTP codes 24 to 32px monospace
  • Usable with images blocked
  • Legible in light and dark mode
  • color-scheme meta + dark-mode media query set
  • Logo and code block survive forced inversion

How should I make transactional email accessible?

Accessibility is not a separate track from the design above, it is the same goal (anyone can read and act in seconds) extended to users on screen readers, with low vision, with motor constraints, or under cognitive load. Transactional email is exactly where this matters most: a blind user resetting a password must be able to find and activate the link as reliably as anyone else.

Concrete requirements

  • Semantic structure. One <h1> stating the purpose, real heading levels, real paragraphs. Layout tables get role="presentation"; data tables (an order summary, a line-item receipt) keep their semantics with <th> headers and a <caption>.
  • Descriptive link text. "Reset your password", not "click here". Screen readers can list links out of context; "click here" is useless in that list.
  • Color contrast. Body text and button labels should meet WCAG 2.1 AA: at least 4.5:1 for normal text, 3:1 for large text (about 18px bold or 24px regular). Never rely on color alone to convey meaning ("the red one is the cancel link"), pair it with text.
  • Alt text. Every meaningful image needs alt; decorative images get alt="" so screen readers skip them. The logo's alt is the company name. Because images are often blocked anyway, the email must read correctly with all alt text in place of images.
  • Don't hide the action in an image. Buttons and codes must be live text, both for image-blocking and for screen readers.
  • Language attribute. <html lang="en"> (or the correct locale) so the screen reader uses the right voice and pronunciation.
  • Respect reduced motion / no motion. Transactional email should not need animation at all; avoid animated GIFs carrying meaning.

Accessibility checklist

  • Single <h1>, real heading hierarchy, semantic paragraphs
  • Layout tables role="presentation"; data tables keep <th>/<caption>
  • All links describe their destination; no "click here"
  • Text and button contrast meets WCAG AA (4.5:1 / 3:1)
  • Meaning never conveyed by color alone
  • Meaningful images have alt; decorative images alt=""
  • Code and primary action are live text, not images
  • lang set on <html>

What should the From, Reply-To, and sender fields be?

Who the email appears to come from is a major driver of both trust and deliverability. Configure these fields deliberately and keep them consistent over time, because consistency is itself a reputation signal.

FieldBest PracticeExample
From NameApp or company name, consistent[App Name]
From EmailSubdomain, real addresshello@mail.yourdomain.com
Reply-ToMonitored inboxsupport@yourdomain.com

Why should I avoid noreply@?

Because users do reply to transactional emails ("my code didn't work", "I didn't order this"). A noreply@ address sends those messages into the void, erodes trust, and misses signals you would want to act on. Use a real, monitored Reply-To instead.

Why send from a subdomain?

Sending transactional (and marketing) mail from a dedicated subdomain such as mail.yourdomain.com isolates your sending reputation from your primary domain. If something goes wrong with sending reputation, it does not poison the domain you use for human, person-to-person email. It also keeps SPF, DKIM, and DMARC configuration cleaner. See Deliverability for the DNS setup.

A stronger pattern at scale is stream separation: send transactional mail from one subdomain and marketing from another (for example mail.yourdomain.com vs news.yourdomain.com, or t.yourdomain.com vs m.yourdomain.com). A burst of marketing complaints then cannot drag your password resets into the spam folder. Some teams go further and use separate IP pools per stream. See Sending Reliability for stream and pool strategy.

What authentication must be in place for these to arrive?

This is the deliverability foundation, summarized here because it directly affects whether your transactional mail lands; the full setup is in Deliverability. Since February 2024, Gmail, Yahoo, and Microsoft enforce bulk-sender requirements that, in practice, every serious sender should meet regardless of volume:

  • SPF (RFC 7208), authorizes which hosts may send for your domain.
  • DKIM (RFC 6376), cryptographically signs the message so receivers can verify it wasn't altered.
  • DMARC (RFC 7489), ties SPF/DKIM to the visible From domain (alignment) and tells receivers what to do on failure.
  • One-click unsubscribe via RFC 8058, required for bulk mail. Pure transactional mail (a single receipt to the person who transacted) is generally out of scope, but if a stream is ambiguous, adding List-Unsubscribe does no harm and protects you. See Compliance.
  • Spam complaint rate below 0.3%, ideally under 0.1%. Every confusing or phishing-looking transactional email contributes complaints to this number.

Copy-paste DNS examples (replace selectors and hosts with your provider's values):

; SPF, one record, lists every authorized sending source
mail.yourdomain.com.  TXT  "v=spf1 include:_spf.your-esp.example -all"

; DKIM, provider gives you a selector ("s1") and a public key
s1._domainkey.mail.yourdomain.com.  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCSq...QAB"

; DMARC, start at p=none to collect reports, then move to quarantine/reject
_dmarc.yourdomain.com.  TXT  "v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com; adkim=s; aspf=s; pct=100"

Verify from the command line before trusting the dashboard:

# SPF
dig +short TXT mail.yourdomain.com
# DKIM (selector "s1")
dig +short TXT s1._domainkey.mail.yourdomain.com
# DMARC
dig +short TXT _dmarc.yourdomain.com

Beyond the three core records, mature senders layer on:

  • ARC (RFC 8617), preserves authentication results across forwarders/mailing lists so legitimately forwarded mail still passes.
  • MTA-STS (RFC 8461) and TLS-RPT (RFC 8460), enforce and report on TLS for inbound mail to your domain, protecting the replies users send back.
  • BIMI, once DMARC is at enforcement (p=quarantine or p=reject), lets your verified logo appear next to the message in supporting clients, a visible trust cue for receipts and security alerts.

How does consistency build reputation?

Mailbox providers learn your patterns. A stable From Name and From Email, sending at a steady cadence with low complaint rates, builds the sender reputation that lands you in the inbox. Frequent changes look suspicious and reset that trust.

Sender checklist

  • From Name is a recognizable app or company name, used consistently
  • From Email on a dedicated sending subdomain
  • Reply-To points to a real, monitored inbox
  • No noreply@ addresses
  • SPF (RFC 7208), DKIM (RFC 6376), DMARC (RFC 7489) aligned for the sending domain (see ch. 03)
  • Transactional and marketing streams separated (subdomain and/or IP pool)
  • Spam-complaint rate held under 0.3% (target <0.1%)

The two things users most often need to act on in a transactional email are a code (OTP or verification) and a link (button). Make both effortless.

OTP and verification codes:

  • Large (24 to 32px), monospace font, so 0 versus O and 1 versus l are unmistakable.
  • Centered, with a clear label ("Your verification code").
  • Include the expiration nearby ("Expires in 10 minutes").
  • Make it copyable: plain text the user can select, not baked into an image.

Buttons:

  • Large and tappable, 44x44px or bigger.
  • Contrasting colors so the button stands out from the background.
  • Clear action text such as "Reset Password", "Verify Email", or "Track Order", never "Click here".
  • HTTPS links only. Never send an action link over plain HTTP.

Why do these details matter?

  • Copyable codes save the user from typing a 6-digit code, which on a phone is genuinely error-prone. Selectable plain text also lets password managers and OS autofill pick it up.
  • Descriptive button text ("Reset Password") is more accessible (screen readers announce the action), more trustworthy (the user knows where it leads), and more clickable.
  • HTTPS only protects the token in the URL from being intercepted, and modern clients and security tools may flag or block HTTP links.

How do I render a code block that copies cleanly?

Render the digits as live, selectable text with letter-spacing for readability, and an explicit background so it survives dark-mode inversion. Keep the markup OTP-autofill-friendly (autocomplete="one-time-code" belongs on the web form, not the email, but a clean numeric block in the body helps OS suggestion banners surface the code):

HTML template code
<table role="presentation" cellpadding="0" cellspacing="0" align="center">
  <tr><td style="padding:8px 0;">
    <p style="font-size:14px;color:#6b7280;margin:0 0 8px;text-align:center;">
      Your verification code
    </p>
    <div style="font-family:'Courier New',monospace;font-size:32px;
                font-weight:700;letter-spacing:6px;text-align:center;
                background:#f3f4f6;border:1px solid #e5e7eb;border-radius:8px;
                padding:16px 24px;color:#111827;">
      482&nbsp;913
    </div>
    <p style="font-size:13px;color:#6b7280;margin:8px 0 0;text-align:center;">
      Expires in 10 minutes.
    </p>
  </td></tr>
</table>

Group digits (e.g. 482 913) for readability, but make sure the underlying selectable text is still easy to copy as a contiguous string if your verification accepts only the raw digits, strip whitespace server-side so a pasted 482 913 validates.

How do I render a "bulletproof" button?

The robust button pattern is an <a> styled as a block, wrapped in VML so Outlook (which ignores CSS padding on links) still renders a real button:

HTML template code
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
  href="https://app.example.com/reset?token=8f3c..." style="height:48px;v-text-anchor:middle;width:280px;"
  arcsize="12%" strokecolor="#4f46e5" fillcolor="#4f46e5">
  <w:anchorlock/>
  <center style="color:#ffffff;font-family:sans-serif;font-size:16px;font-weight:bold;">
    Reset Password
  </center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="https://app.example.com/reset?token=8f3c..."
   style="display:inline-block;background:#4f46e5;color:#ffffff;
          font-family:sans-serif;font-size:16px;font-weight:bold;
          text-decoration:none;padding:14px 28px;border-radius:8px;
          min-height:44px;line-height:20px;">
  Reset Password
</a>
<!--<![endif]-->

The padding:14px 28px plus line-height keeps the tap target at or above 44px tall. Use a brand color with sufficient contrast against white text (verify 4.5:1).

Yes. Under every button, include the full URL as selectable text:

Button not working? Paste this link into your browser: https://app.yourdomain.com/reset?token=…

This rescues users whose client blocks the button, strips the styling, or doesn't render the HTML at all.

The link and code are bearer credentials, whoever holds them can act. Design them like secrets, not URLs:

  • High entropy. At least 128 bits of randomness from a CSPRNG, never a sequential ID or a guessable hash. In Node:
Code for your engineers (TypeScript)
import { randomBytes, timingSafeEqual, createHash } from "node:crypto";

// Generate a single-use token (32 bytes = 256 bits, URL-safe).
function createResetToken(): { token: string; lookupHash: string } {
  const token = randomBytes(32).toString("base64url");
  // Store only a hash of the token, never the raw value.
  const lookupHash = createHash("sha256").update(token).digest("hex");
  return { token, lookupHash };
}

// Validate in constant time against the stored hash.
function tokenMatches(presented: string, storedHash: string): boolean {
  const presentedHash = createHash("sha256").update(presented).digest();
  const stored = Buffer.from(storedHash, "hex");
  return presentedHash.length === stored.length &&
         timingSafeEqual(presentedHash, stored);
}
  • Single-use. Invalidate the token the moment it is consumed, so a leaked link (forwarded email, browser history, corporate proxy logs) cannot be replayed.
  • Short TTL. Reset/magic-link tokens: 15–60 minutes. OTP codes: 5–10 minutes. Email-verification links can be longer (up to 24 hours) because the risk is lower.
  • Bound to one purpose and one account. A reset token must not also work as a login token.
  • Stored hashed. Persist only sha256(token); if your DB leaks, the raw tokens don't.
  • Scrubbed from logs and analytics. Never let the token land in access logs, click-tracking redirects you don't control, or referrer headers. If you use click tracking on a stream, exclude security links from it.
  • Codes are 24 to 32px monospace, centered, labeled
  • Expiration shown next to the code
  • Codes are selectable plain text (not an image)
  • Buttons of 44x44px or larger with high contrast
  • Button text describes the action, never "Click here"
  • All action links are HTTPS
  • Plain-text fallback URL provided under every button
  • Tokens: ≥128-bit CSPRNG, single-use, short TTL, stored hashed, validated in constant time
  • Security links excluded from click tracking; tokens scrubbed from logs

How do I send the email correctly from the backend?

What a good send call carries (a provider-neutral shape):

FieldWhy it's there
toThe recipient address.
from (name + address)A recognizable sender on your sending subdomain.
replyToA real, monitored inbox, never noreply@.
subjectA specific subject line, no secrets.
html + textBoth MIME parts, the text part protects deliverability and accessibility.
headersExtra headers, e.g. X-Entity-Ref-ID to classify the stream.
idempotencyKeyA key derived from the event ID, the same trigger can't send twice.

The template is half the job; the send call is the other half. Below is a provider-neutral shape that captures the patterns that actually prevent incidents: idempotency, a real Reply-To, both MIME parts, and the headers that matter.

Code for your engineers (TypeScript)
// A thin abstraction over whichever ESP/SMTP transport you use.
// Any provider (SES, Postmark, SendGrid, Mailgun, Resend, Brevo, ...)
// can implement this interface.
interface EmailMessage {
  to: string;
  from: { name: string; email: string };
  replyTo: string;
  subject: string;
  html: string;
  text: string;
  headers?: Record<string, string>;
  idempotencyKey?: string;   // dedupe identical sends
}

interface EmailClient {
  send(msg: EmailMessage): Promise<{ messageId: string }>;
}

async function sendPasswordReset(
  client: EmailClient,
  user: { email: string },
  resetUrl: string,
  eventId: string,            // stable id of the triggering event
) {
  return client.send({
    to: user.email,
    from: { name: "Acme", email: "no-reply-but-monitored@mail.example.com" },
    replyTo: "support@example.com",
    subject: "Reset your password for Acme",
    html: renderResetHtml({ resetUrl }),
    text: renderResetText({ resetUrl }),
    // Idempotency: a retry with the same key must not send twice.
    idempotencyKey: `pwd-reset:${eventId}`,
    headers: {
      // Help receivers and your own tooling classify the stream.
      "X-Entity-Ref-ID": eventId,
    },
  });
}

If your provider does not expose a native idempotency key, implement dedup yourself: before sending, INSERT ... ON CONFLICT DO NOTHING a row keyed on idempotencyKey; only send if the insert created a row. The reliability details, retry/backoff, the dedup window, what to do when the provider call times out after possibly succeeding, are in Sending Reliability.

Should I add List-Unsubscribe to transactional mail?

For a genuinely transactional, one-to-one message (a receipt to the buyer, a reset to the requester), one-click unsubscribe (RFC 8058) is not required. But two cases call for caution:

  • Ambiguous streams (an "order shipped" that also nudges related products) drift toward commercial and should carry List-Unsubscribe + List-Unsubscribe-Post.
  • High volume from one subdomain can get flagged as bulk regardless of content; adding the headers is harmless and demonstrates good faith.

When you do add them, both headers are required for the one-click flow to work:

List-Unsubscribe: <https://example.com/unsub?token=...>, <mailto:unsub@example.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

See Compliance and List Management for the decision boundary and the server-side handling of the POST.


How do I handle the things that go wrong?

A robust transactional email system anticipates the unhappy paths: the email didn't arrive, the link expired, or the user didn't request this at all. Handling these gracefully prevents lockouts, support load, and security incidents.

How should resend work?

  • Allow resend after 60 seconds. Soon enough that a user who didn't get the email isn't stuck, but long enough to avoid duplicate-send spam (the original is often just delayed).
  • Limit attempts to 3 per hour. This prevents abuse (using your system to spam a third party) and protects your sending reputation.
  • Show a countdown timer. Tell the user when they can resend ("Resend available in 0:47") so they don't hammer the button.

Implement the cooldown and cap server-side, never trust a client timer. A simple rate-limit keyed on the destination address plus the email type:

Code for your engineers (TypeScript)
// Returns the seconds the caller must wait, or 0 if a resend is allowed.
async function resendGate(
  store: RateStore,
  key: string,             // e.g. `resend:pwd-reset:${emailHash}`
): Promise<number> {
  const COOLDOWN_S = 60;
  const MAX_PER_HOUR = 3;

  const recent = await store.timestampsWithin(key, 3600); // last hour
  if (recent.length >= MAX_PER_HOUR) {
    const oldest = recent[0];
    return 3600 - (Date.now() / 1000 - oldest); // wait until window frees
  }
  const last = recent[recent.length - 1];
  if (last && Date.now() / 1000 - last < COOLDOWN_S) {
    return COOLDOWN_S - (Date.now() / 1000 - last);
  }
  await store.record(key, Date.now() / 1000);
  return 0;
}

Key the limit on a hash of the destination address, not the user ID, so that an unauthenticated "forgot password" form cannot be used to flood an arbitrary inbox. Critically, do not reveal whether the address exists, see enumeration below.

  • Show a clear "This link has expired" message, not a generic 404 or error page.
  • Offer to send a new link right there, in one click.
  • Provide a support contact for users who keep hitting expiration.

A dead link with no path forward is one of the most common causes of account abandonment.

Distinguish the failure cases so the recovery page can respond correctly, but never leak why a token is invalid to an attacker probing tokens, to the user the message can be specific, to a scraper it should be uniform:

Token stateWhat the user didRecovery page should offer
ExpiredWaited too long"This link expired, send a new one" + button
Already usedClicked twice / forwarded"This link was already used, request a fresh one"
Unknown / malformedTruncated by a client, or probingGeneric "This link isn't valid, request a new one"
ValidNormal pathProceed to the reset/verify form

Email clients sometimes pre-fetch links (security scanners, link preview, Outlook Safe Links) and can consume a single-use token before the human clicks. Mitigate by: (a) not consuming the token on GET, show a confirmation page and consume only on the explicit POST/button click; (b) allowing a token to be re-presented within its TTL until an action is actually completed.

What about "I didn't request this"?

Include an "I didn't request this" notice in password resets, OTPs, and security alerts. It is a key security and trust mechanism.

  • Include it in: password resets, OTPs, security alerts, email-change confirmations.
  • Link to a security contact so a genuinely worried user can escalate.
  • Log the clicks for monitoring. A spike in "I didn't request this" clicks can indicate a credential-stuffing attack or that your address has been targeted.

Why this matters: transactional emails are a front line of account security. If an attacker triggers a password reset for a victim, the legitimate user's only signal is that email. A clear "didn't request this? secure your account" path turns a scary, confusing message into a reassuring one and gives your security team an early-warning signal.

How do I avoid leaking who has an account (enumeration)?

Account-recovery and verification flows are a classic enumeration oracle: if "forgot password" says "no account with that email" for one address and "we sent you a link" for another, an attacker can map your user base. Defenses:

  • Uniform response. Always return the same neutral message ("If an account exists for that address, we've sent a reset link") and the same HTTP status and timing, whether or not the address exists.
  • Send a "no account here" email to addresses that don't match an account, this both keeps the visible flow uniform and helps a real user who mistyped or used the wrong address realize their mistake. (Use sparingly and rate-limit; it can be abused as a spam vector.)
  • Constant-time behavior. Don't let "exists" be faster than "doesn't exist"; do the same work (or a comparable delay) in both branches.
  • Rate-limit by destination and by source IP to blunt bulk probing.

What about delivery failures (bounces, spam, silent drops)?

The user staring at an empty inbox needs you to have already reacted. Wire your provider's webhooks (delivery, bounce, complaint, deferral) into your system and act on them:

  • Hard bounce → the address is invalid; surface "we couldn't deliver to that address" and let the user correct it. Suppress further sends to it (see List Management).
  • Soft bounce / deferral → transient; your provider retries. Don't show an error yet.
  • Complaint (user hit "spam") → suppress and investigate; it counts against your <0.3% budget.
  • Delivered but not opened → may simply be in spam; this is why authentication and stream separation matter more than any UI fix.

The event pipeline, signature verification, idempotent processing, and suppression-list updates, is the subject of Webhooks & Events. Verify webhook authenticity before acting: with the Standard Webhooks scheme, the provider sends webhook-id, webhook-timestamp, and webhook-signature headers, and you verify an HMAC over ${id}.${timestamp}.${body}:

Code for your engineers (TypeScript)
import { createHmac, timingSafeEqual } from "node:crypto";

// Standard Webhooks verification, provider-neutral, no vendor SDK.
function verifyWebhook(
  secret: string,            // base64, often prefixed "whsec_"
  headers: { id: string; timestamp: string; signature: string },
  rawBody: string,
): boolean {
  // Reject stale deliveries to stop replay (5-minute window).
  const ageS = Math.abs(Date.now() / 1000 - Number(headers.timestamp));
  if (ageS > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signed = `${headers.id}.${headers.timestamp}.${rawBody}`;
  const expected = createHmac("sha256", key).update(signed).digest("base64");

  // header may carry several space-separated "v1,<sig>" values
  return headers.signature.split(" ").some((part) => {
    const sig = part.split(",")[1] ?? part;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Always verify against the raw request body (not a re-serialized JSON object) or the HMAC will not match.

What are the most common mistakes here?

  • Resending on every page reload with no cooldown, which floods the user and trips spam filters.
  • Letting an expired link dead-end on a raw 404 with no way to request a new one.
  • Burying the "didn't request this" link in the footer where a worried user will not find it.
  • Sending the action link over HTTP, which leaks the token and can be blocked by the client.
  • Putting the code or button inside an image, so it vanishes when images are off.
  • Consuming a single-use token on GET, so link-prefetch scanners burn it before the human clicks.
  • Returning different responses for "account exists" vs "doesn't", handing attackers an enumeration oracle.
  • Rate-limiting resend by user ID instead of destination address, so the form becomes an open relay to flood any inbox.
  • Trusting webhook payloads without verifying the signature against the raw body.
  • Putting the OTP code or token in the subject/pre-header where lock screens expose it.

Error-handling checklist

  • Resend allowed after 60s, capped at 3/hour, enforced server-side
  • Resend rate-limited by destination address (not user ID)
  • Countdown timer shown for resend
  • Expired links show a clear message plus one-click re-send
  • Support contact offered on the expired-link page
  • Single-use tokens consumed on action (POST), not on prefetch (GET)
  • Recovery flows give a uniform response (no account enumeration)
  • "I didn't request this" notice on resets, OTPs, and security alerts
  • Security contact linked from that notice
  • "Didn't request this" clicks are logged and monitored
  • Bounce/complaint webhooks verified (Standard Webhooks) and acted on

How do I test a transactional template before it ships?

A template that looks right in your design tool can still break in the wild. Build a repeatable pre-ship test pass:

  • Render across clients. At minimum: iOS Mail, Gmail app (Android + iOS), Gmail web, Apple Mail (macOS), Outlook (Windows desktop, the worst renderer, uses Word's engine), and Outlook web. Use a rendering service or a manual device matrix.
  • Images off. Disable image loading and confirm the email is fully usable, code, button, and meaning all present as live text.
  • Dark mode. Check all three behaviors (no transform, partial, full inversion); confirm the logo and code block survive.
  • Plain-text part. Read it on its own; it must contain the code/link and make sense.
  • Truncation. Preview the subject and pre-header at notification width (~30 chars).
  • Links. Click every link from a real inbox; confirm HTTPS, correct destination, and that security links are excluded from click tracking.
  • Spam-filter sanity. Run the message through an authentication/spam check (SPF/DKIM/DMARC pass, no broken HTML, reasonable text-to-image ratio).
  • Localization. If you send in multiple languages, verify length-sensitive elements (buttons, subject) don't overflow in the longest locale, and that the lang attribute is correct.

Pre-ship test checklist

  • Rendered in the major clients incl. Outlook desktop
  • Usable with images blocked
  • Verified in light and dark mode (all inversion behaviors)
  • Plain-text part read and validated
  • Subject/pre-header checked at notification width
  • Every link clicked from a real inbox; HTTPS; no tracking on security links
  • SPF/DKIM/DMARC pass on the sent message
  • Length-sensitive elements verified in the longest supported locale

Is there one checklist I can run before sending?

Yes. Use this as a pre-send review for any new transactional template.

  • Purpose: one email, one clear purpose, one primary action
  • Subject: specific, identifies the account or order, no secrets
  • Pre-header: set explicitly, under 90 chars, reinforces the subject
  • Structure: purpose plus action above the fold, secondary info last
  • Mobile: single column, 44px buttons, 16px+ text, 24 to 32px codes
  • Accessibility: semantic headings, descriptive links, AA contrast, alt text
  • Sender: consistent From Name, subdomain From, monitored Reply-To, no noreply@
  • Auth: SPF/DKIM/DMARC aligned; stream separated; complaint rate <0.3%
  • Codes and links: copyable codes, descriptive HTTPS buttons, plain-text fallback, hashed single-use tokens
  • Timing & idempotency: sent within seconds of the trigger; same trigger can't send twice
  • Errors: resend rules, expired-link recovery, enumeration-safe, "didn't request this"
  • Rendering: tested in major clients, dark mode, and with images off

If this is happening in production…

Don't guess. Get a read on it

Book a 30-min reviewWe'll look at where your mail goes today and what it would take to fix it (no obligation).Book a deliverability call

Seen this in the wild: read the case studies