EMAILTEMPLATE

Transactional Email Set

Every product that has accounts needs exactly three emails before it needs any others: a welcome when someone signs up, a receipt when someone pays, and a reset when someone forgets their password. Ship these three and you can ignore email for months. Skip them and users assume the product is abandoned, accountants chase you for receipts, and locked-out customers simply leave.

Transactional email is not marketing email, legally or socially. These messages are triggered by something the user did, they contain information the user needs, and that's the entire justification for sending them. The moment a "quick product update" sneaks into your receipt template you've converted a message people trust into one they filter, and you've done it in the one channel where trust was the whole point.

Two of the three have teeth. Receipts must be idempotent: a payment webhook that retries, and they all retry, must not produce a second receipt, because a duplicate receipt reads as a duplicate charge and generates a support ticket every single time. And the reset email is a security surface: it must not confirm whether an account exists, and its token must be single-use, short-lived, and never logged. The spec below hands your agent all of that as requirements, not suggestions.

Prerequisites

  • An email API account (Resend, Postmark, or similar) with your domain verified. Verification is what keeps you out of spam folders; don't skip it.
  • The API key in a server-side environment variable.
  • An app with signup, payment, and password auth events to hook into.
markdown
# Build: transactional email set (welcome, receipt, reset)

## Ground rules for all three

- One send function wraps the provider. Sketch:

  ```typescript
  // lib/email.ts - the only file that talks to the provider
  export async function sendEmail(opts: {
    to: string;
    subject: string;
    text: string;            // always build the plain-text version
    html?: string;
    idempotencyKey: string;  // dedupe key, REQUIRED (see receipts)
  }) { /* provider call + a sent-log check on idempotencyKey */ }
  ```

  Before sending, check the key against a sent log (a DB table:
  key, sent_at). Seen key -> skip silently. This makes every email
  in the app safe to retry.
- From: [you@your-verified-domain]. Reply-to: [a monitored inbox].
  Sending from an address that can't receive replies tells users
  not to reply to you, and eventually they'll listen.
- Every email has a plain-text body. HTML is optional polish.
- No marketing content in any of these. No "while you're here."
- API key from an environment variable only; listed in `.env.example`
  as a placeholder; never in the client bundle.
- Send AFTER the triggering transaction commits. An email about a
  signup that then rolled back is a lie with your name on it.

## 1. Welcome (trigger: account created)

- Subject: "Welcome to [product]"
- Body: one line of greeting, the ONE next action that makes the
  product useful ([e.g. "create your first project"]) as a link, and
  where to get help. Under 120 words. Nobody rereads a welcome email.
- Idempotency key: `welcome:{user_id}`.
- Send it async if signup is user-facing; the account creation must
  not fail because the email provider hiccuped.

## 2. Receipt (trigger: payment succeeded webhook)

- Subject: "Receipt: [product], {amount} on {date}"
- Body: amount, date, what was purchased, last 4 of card if available,
  [business legal name and address], and a link to [billing page].
- Idempotency key: `receipt:{payment_id}`. NOT the user id, NOT the
  timestamp. Payment webhooks retry by design; the payment id is the
  one value that's identical on every retry of the same charge.
- Numbers come from the webhook payload verbatim. Do not recompute
  totals in the email layer; two sources of arithmetic will disagree
  eventually, and it'll be in front of an accountant.

## 3. Password reset (trigger: reset requested)

- Subject: "Reset your [product] password"
- Body: one link with the token, expiry stated plainly ("this link
  works for [30 minutes]"), and one line: "If you didn't request
  this, ignore this email. Your password is unchanged."
- Token rules, non-negotiable:
  - Random via a cryptographic generator; at least 32 bytes.
  - Stored hashed, exactly like a password. A tokens table in
    plaintext is a master key to every account.
  - Single-use: consumed on successful reset. Expired or used
    tokens fail identically.
  - Expiry: [30 minutes]. Never appears in logs or analytics.
- The request endpoint responds identically whether or not the email
  has an account: "If an account exists, we've sent a link."
  Anything else lets attackers enumerate your user list.
- Rate limit: max [3] reset requests per email address per hour.

## Done means

- Firing the same payment webhook twice sends exactly one receipt.
- A reset request for a nonexistent email returns the same response,
  in roughly the same time, as one for a real account.
- A reset link fails on second use and after expiry.
- `git grep` for the API key finds env access and `.env.example` only.
- All three emails render legibly as plain text.

Adaptation notes:

  • These three generalize: order confirmations are receipts with a different trigger, email verification is a reset with a different landing page. Reuse the send wrapper and the idempotency discipline for every email the app ever sends.
  • The sent-log table doubles as an audit trail. When a user says "I never got a receipt," you can answer with a timestamp instead of a shrug.
  • If signups are user-facing, queue the welcome email (even a jobs table you poll) so a provider outage never blocks account creation. The receipt and reset are already async by nature; they hang off webhooks and requests.
  • Test the reset flow as an attacker for ten minutes: request resets for emails that don't exist, replay a used token, try an expired one. Your agent implemented the rules; verify them the way §6.3 taught you, with attempts designed to fail.
  • The mistake people make: keying receipt idempotency on the user and a date because it was easy. First customer to buy two things in one day gets one receipt, and the missing one is a support ticket. The payment id exists precisely for this.