Skip to content

Email templates

Fourteen editable transactional templates, 28 message variants, and one typed HTML/plain-text renderer.

Open the email collection

Render after a committed event

Render on your server after account, billing, or order state is confirmed. Choose the matching variant from the registry; a requested return is different from an issued refund. Generate authentication links and one-time codes on your server. Supply actual line items, dates, address, and support links; the studio uses fictional fixtures.

import { renderEmail } from "@/lib/emails/render";

const message = renderEmail("shipping-update", eventData, { variant: "shipped" });
// Your server-side mail adapter receives:
// { to, subject: message.subject, html: message.html, text: message.text }
// Store a unique event delivery key to prevent duplicate sends.

Design and compatibility

The output is a complete HTML document with a hidden preheader, semantic heading, presentation tables, inline colors/spacing, system fonts, and a fluid 600-pixel container with an Outlook conditional-width wrapper. It loads no scripts, remote fonts, images, or trackers. The preview iframe is sandboxed and links cannot open new windows. Browser previews are not certification in Outlook, Gmail, or Apple Mail; test your final content in the clients your audience uses. Can I email documents client-specific support.

Text is escaped and links must be absolute HTTPS URLs without credentials. Brand accents accept six-digit hex colors; choose accessible contrast. LTR and RTL output are available. The English copy is editable in the registry. Prices use integer cents for the supported two-decimal currencies; dates are supplied display strings. These are account/order messages, not a marketing subscription system.

API and files

  • renderEmail(templateId, data, options) returns subject, preheader, html, and text; invalid templates, variants, values, and URLs throw.
  • EmailData holds company, recipient, reference, URLs, address, code, date, carrier, tracking, device, location, currency, and item name/quantity/unitCents.
  • options selects variant, accent, and direction. Every template has two variants.
  • The independent starter includes the framework-independent renderer, registry, and tests. No sending service or credentials are configured.

Editable registry

export type EmailSection =
  | "checklist"
  | "code"
  | "invoice"
  | "order"
  | "tracking"
  | "security"
  | "summary";
export interface EmailVariant {
  id: string;
  label: string;
  subject: string;
  title: string;
  preheader: string;
  message: string;
  action: string;
  section: EmailSection;
  tone: "brand" | "success" | "warning";
}
export interface EmailTemplate {
  id: string;
  name: string;
  category: "Account" | "Billing" | "Commerce";
  description: string;
  variants: EmailVariant[];
}
const variant = (
  id: string,
  label: string,
  title: string,
  message: string,
  action: string,
  section: EmailSection = "summary",
  tone: EmailVariant["tone"] = "brand",
): EmailVariant => ({
  id,
  label,
  subject: title,
  title,
  preheader: message,
  message,
  action,
  section,
  tone,
});
export const emailTemplates: EmailTemplate[] = [
  {
    id: "welcome",
    name: "Welcome",
    category: "Account",
    description: "A calm first step into a new workspace.",
    variants: [
      variant(
        "personal",
        "New member",
        "Good things start here.",
        "Your workspace is ready. Bring your ideas, invite your team, and make something worth sharing.",
        "Open your workspace",
        "checklist",
      ),
      variant(
        "team",
        "Team launch",
        "A place for your best work.",
        "The team is together. Here are three small steps to make this space your own.",
        "Meet your workspace",
        "checklist",
      ),
    ],
  },
  {
    id: "verify-email",
    name: "Verify email",
    category: "Account",
    description: "Confirm an address with a clear, time-bound action.",
    variants: [
      variant(
        "first",
        "First request",
        "One last step. Make it yours.",
        "Confirm this email address to finish setting up your account. This link expires in 24 hours.",
        "Verify email address",
      ),
      variant(
        "reminder",
        "Reminder",
        "Your workspace is waiting.",
        "You have not verified your address yet. Use your latest verification link to finish setup.",
        "Confirm my address",
      ),
    ],
  },
  {
    id: "password-reset",
    name: "Password reset",
    category: "Account",
    description: "A focused recovery email with an expiry and safe fallback.",
    variants: [
      variant(
        "requested",
        "Requested",
        "A fresh start for your password.",
        "We received a password reset request. Use the secure link below within 30 minutes. If this was not you, you can ignore this email.",
        "Reset your password",
      ),
      variant(
        "changed",
        "Changed",
        "Your password has been updated.",
        "Your account password was changed. If you did not make this change, review your account security immediately.",
        "Review account security",
        "security",
        "success",
      ),
    ],
  },
  {
    id: "team-invitation",
    name: "Team invitation",
    category: "Account",
    description:
      "Introduce a workspace invitation and the access being offered.",
    variants: [
      variant(
        "member",
        "Member access",
        "You’re invited to the studio.",
        "Alex Morgan invited you to collaborate. Accept the invitation to join the workspace as a member. This invitation expires in seven days.",
        "Accept invitation",
        "checklist",
      ),
      variant(
        "guest",
        "Guest access",
        "A little room to collaborate.",
        "You have been invited as a guest to a shared project. Your access is limited to the project you were invited to.",
        "View invitation",
      ),
    ],
  },
  {
    id: "sign-in-code",
    name: "Sign-in code",
    category: "Account",
    description: "An accessible one-time code with clear expiration.",
    variants: [
      variant(
        "login",
        "Sign in",
        "Your sign-in code is here.",
        "Enter this code in the sign-in window. It expires in ten minutes and can be used once. Never share it with anyone.",
        "Return to sign in",
        "code",
      ),
      variant(
        "confirm",
        "Confirm action",
        "Let’s make sure it’s you.",
        "Use this one-time code to confirm your account action. If you did not request it, do not share or use the code.",
        "Review account activity",
        "code",
      ),
    ],
  },
  {
    id: "security-alert",
    name: "Security alert",
    category: "Account",
    description: "Device and session information with a direct review action.",
    variants: [
      variant(
        "new-device",
        "New device",
        "A new sign-in to your account.",
        "We noticed a sign-in from a device we have not seen before. If this was you, no action is needed.",
        "Review active sessions",
        "security",
        "warning",
      ),
      variant(
        "revoked",
        "Session revoked",
        "That session is now closed.",
        "The selected session has been revoked. Other active sessions are unchanged. Review them if you still have concerns.",
        "Review account security",
        "security",
        "success",
      ),
    ],
  },
  {
    id: "invoice",
    name: "Invoice",
    category: "Billing",
    description: "An itemized invoice with amount, reference, and due date.",
    variants: [
      variant(
        "issued",
        "Issued",
        "Your invoice, ready to review.",
        "Here is a clear breakdown of your workspace subscription. Review the details and pay securely from your billing portal.",
        "View and pay invoice",
        "invoice",
      ),
      variant(
        "due",
        "Due soon",
        "A small reminder about your invoice.",
        "This invoice is due soon. If you have already paid, your billing portal will show the latest status.",
        "Review invoice",
        "invoice",
        "warning",
      ),
    ],
  },
  {
    id: "payment-receipt",
    name: "Payment receipt",
    category: "Billing",
    description: "A paid receipt with itemized totals and a billing link.",
    variants: [
      variant(
        "paid",
        "Paid",
        "All paid. All set.",
        "Your payment has been recorded. Keep this receipt for your records, or find it any time in your billing portal.",
        "View receipt",
        "invoice",
        "success",
      ),
      variant(
        "renewed",
        "Renewal",
        "Another month of good work.",
        "Your subscription renewed successfully. Your team can keep working without interruption.",
        "Manage subscription",
        "invoice",
        "success",
      ),
    ],
  },
  {
    id: "payment-failed",
    name: "Payment failed",
    category: "Billing",
    description:
      "A respectful payment recovery message without exposed card details.",
    variants: [
      variant(
        "retry",
        "Update payment",
        "A quick update to keep things moving.",
        "We could not collect your latest subscription payment. Review your payment method securely in the billing portal.",
        "Update payment method",
        "invoice",
        "warning",
      ),
      variant(
        "action",
        "Action needed",
        "Your billing needs a little attention.",
        "Your payment is still outstanding. Check your account for the current balance and any effect on your subscription.",
        "Review billing",
        "invoice",
        "warning",
      ),
    ],
  },
  {
    id: "trial-ending",
    name: "Trial ending",
    category: "Billing",
    description:
      "An honest trial reminder with no invented urgency or charges.",
    variants: [
      variant(
        "three-days",
        "Three days left",
        "A few more days to explore.",
        "Your trial ends in three days. Choose a plan if you would like to keep the workspace’s paid features. Check your account for your exact trial terms.",
        "Explore your plans",
        "checklist",
      ),
      variant(
        "ended",
        "Trial ended",
        "Your next chapter is up to you.",
        "Your trial has ended. Review the available plans and your workspace’s current access from your account.",
        "Review workspace plans",
        "summary",
      ),
    ],
  },
  {
    id: "order-confirmation",
    name: "Order confirmation",
    category: "Commerce",
    description: "Order lines, totals, and a stable order reference.",
    variants: [
      variant(
        "confirmed",
        "Confirmed",
        "A thoughtful choice. Thank you.",
        "We have received your order. You can review its details and follow each next step from your order page.",
        "View your order",
        "order",
        "success",
      ),
      variant(
        "processing",
        "Preparing",
        "We’re getting your order ready.",
        "Your order is being prepared. We will share tracking details after it has been handed to the carrier.",
        "Follow your order",
        "order",
      ),
    ],
  },
  {
    id: "shipping-update",
    name: "Shipping update",
    category: "Commerce",
    description: "Carrier, tracking reference, and shipment status.",
    variants: [
      variant(
        "shipped",
        "Shipped",
        "Your order is on its way.",
        "Your parcel has been handed to the carrier. Follow the tracking link for the latest delivery information.",
        "Track your parcel",
        "tracking",
        "success",
      ),
      variant(
        "delivered",
        "Delivered",
        "A good arrival.",
        "The carrier marked your parcel as delivered. If you cannot find it, check the tracking details and contact support.",
        "Review delivery details",
        "tracking",
        "success",
      ),
    ],
  },
  {
    id: "refund-confirmation",
    name: "Refund update",
    category: "Commerce",
    description: "Distinguish an approved request from a completed refund.",
    variants: [
      variant(
        "approved",
        "Approved",
        "Your return has been approved.",
        "We have approved your return request. Review the instructions and keep your order reference handy. A refund is not issued until the return is processed.",
        "View return instructions",
        "order",
      ),
      variant(
        "refunded",
        "Refund issued",
        "Your refund has been issued.",
        "We have recorded your refund to the original payment method. Your bank or payment provider controls when the credit becomes visible.",
        "View refund details",
        "invoice",
        "success",
      ),
    ],
  },
  {
    id: "subscription-update",
    name: "Subscription update",
    category: "Billing",
    description: "Clear plan-change and cancellation acknowledgements.",
    variants: [
      variant(
        "updated",
        "Plan updated",
        "A plan that fits your next step.",
        "Your subscription has been updated. Review the billing portal for your effective date, current plan, and any prorated charges or credits.",
        "Review subscription",
        "summary",
        "success",
      ),
      variant(
        "cancelled",
        "Cancelled",
        "Your cancellation is confirmed.",
        "Your subscription is scheduled to end on the date shown in your billing portal. You can review access and cancellation details there.",
        "View subscription details",
        "summary",
      ),
    ],
  },
];
export interface EmailItem {
  name: string;
  quantity: number;
  unitCents: number;
}
export interface EmailData {
  company: string;
  recipient: string;
  reference: string;
  actionUrl: string;
  supportUrl: string;
  address: string;
  code: string;
  date: string;
  carrier: string;
  tracking: string;
  device: string;
  location: string;
  items: EmailItem[];
  currency: string;
}
export const sampleEmailData: EmailData = {
  company: "Apex Studio",
  recipient: "Alex",
  reference: "APX-2048",
  actionUrl: "https://example.com/account",
  supportUrl: "https://example.com/support",
  address: "24 Studio Lane · Portland, OR 97205",
  code: "482 196",
  date: "November 12, 2026",
  carrier: "Studio Parcel",
  tracking: "ST-2048-7821",
  device: "Chrome on macOS",
  location: "Portland, United States",
  currency: "USD",
  items: [
    { name: "Studio notebook", quantity: 2, unitCents: 1800 },
    { name: "Everyday canvas tote", quantity: 1, unitCents: 2400 },
  ],
};

Renderer source

import { emailTemplates, type EmailData, type EmailSection } from "./templates";
export function escapeEmail(value: unknown): string {
  return String(value).replace(
    /[&<>"']/g,
    (character) =>
      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
        character
      ]!,
  );
}
function safeUrl(value: string) {
  const url = new URL(value);
  if (url.protocol !== "https:" || url.username || url.password)
    throw new Error(
      "Email links must be absolute HTTPS URLs without credentials.",
    );
  return url.href;
}
export function emailAmount(cents: number, currency: string) {
  if (!Number.isSafeInteger(cents) || cents < 0)
    throw new Error("Email amounts must be non-negative integer cents.");
  if (!["USD", "EUR", "GBP", "CAD", "AUD"].includes(currency))
    throw new Error(
      "Use a supported two-decimal currency: USD, EUR, GBP, CAD, or AUD.",
    );
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
    cents / 100,
  );
}
export interface EmailRenderOptions {
  variant?: string;
  accent?: string;
  direction?: "ltr" | "rtl";
}
export interface RenderedEmail {
  subject: string;
  preheader: string;
  html: string;
  text: string;
}
export function renderEmail(
  templateId: string,
  data: EmailData,
  options: EmailRenderOptions = {},
): RenderedEmail {
  const template = emailTemplates.find((item) => item.id === templateId);
  if (!template) throw new Error("Unknown email template.");
  const variant = options.variant
    ? template.variants.find((item) => item.id === options.variant)
    : template.variants[0];
  if (!variant) throw new Error("Unknown email variant.");
  const accent = options.accent ?? "#087f5b";
  if (!/^#[0-9a-f]{6}$/i.test(accent))
    throw new Error("Accent must be a six-digit hex color.");
  if (!data.company.trim() || !data.recipient.trim() || !data.reference.trim())
    throw new Error("Company, recipient, and reference are required.");
  const action = safeUrl(data.actionUrl);
  const support = safeUrl(data.supportUrl);
  const e = escapeEmail;
  const dir = options.direction === "rtl" ? "rtl" : "ltr";
  const total = data.items.reduce((sum, item) => {
    if (
      !item.name.trim() ||
      !Number.isSafeInteger(item.quantity) ||
      item.quantity < 1 ||
      !Number.isSafeInteger(item.unitCents) ||
      item.unitCents < 0
    )
      throw new Error(
        "Email line items need a name, positive integer quantity, and non-negative integer unit cents.",
      );
    return sum + item.quantity * item.unitCents;
  }, 0);
  emailAmount(total, data.currency);
  const detail = (label: string, value: string) =>
    `<tr><td style="padding:10px 0;border-bottom:1px solid #e4e8e7;color:#57645f;font-size:13px;">${e(label)}</td><td style="padding:10px 0;border-bottom:1px solid #e4e8e7;text-align:${options.direction === "rtl" ? "left" : "right"};font-size:13px;font-weight:bold;">${e(value)}</td></tr>`;
  const sections: Record<EmailSection, string> = {
    checklist: [
      "Make the workspace your own",
      "Bring the right people together",
      "Start with one meaningful project",
    ]
      .map(
        (text, index) =>
          `<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td width="40" valign="top" style="padding:10px 0;color:${accent};font-weight:bold;">0${index + 1}</td><td style="padding:10px 0;font-size:14px;">${e(text)}</td></tr></table>`,
      )
      .join(""),
    code: `<div style="border:1px solid #dce5e1;background:#f6f8f7;padding:24px 12px;text-align:center;font-family:Courier New,monospace;font-size:34px;letter-spacing:8px;font-weight:bold;" dir="ltr">${e(data.code)}</div><p style="font-size:12px;color:#57645f;text-align:center;">One-time code · expires in 10 minutes</p>`,
    invoice: `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;font-size:13px;"><caption style="text-align:${options.direction === "rtl" ? "right" : "left"};font-weight:bold;padding-bottom:12px;">${e(data.reference)} · ${e(data.date)}</caption><thead><tr><th scope="col" style="text-align:${dir === "rtl" ? "right" : "left"};padding:10px 0;border-bottom:1px solid #dce5e1;">Description</th><th scope="col" style="text-align:${dir === "rtl" ? "left" : "right"};padding:10px 0;border-bottom:1px solid #dce5e1;">Amount</th></tr></thead><tbody>${data.items.map((item) => `<tr><td style="padding:12px 0;border-bottom:1px solid #e4e8e7;">${e(item.name)} × ${item.quantity}</td><td style="padding:12px 0;border-bottom:1px solid #e4e8e7;text-align:${dir === "rtl" ? "left" : "right"};white-space:nowrap;">${e(emailAmount(item.quantity * item.unitCents, data.currency))}</td></tr>`).join("")}<tr><td style="padding:16px 0;font-weight:bold;">Total</td><td style="padding:16px 0;text-align:${dir === "rtl" ? "left" : "right"};font-size:22px;font-weight:bold;">${e(emailAmount(total, data.currency))}</td></tr></tbody></table>`,
    order: `<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${detail("Order reference", data.reference)}${data.items.map((item) => detail(`${item.quantity} × ${item.name}`, emailAmount(item.quantity * item.unitCents, data.currency))).join("")}${detail("Total", emailAmount(total, data.currency))}</table>`,
    tracking: `<div style="background:#f6f8f7;border:1px solid #dce5e1;padding:20px;"><p style="margin:0 0 8px;color:${accent};font-size:12px;font-weight:bold;letter-spacing:2px;">${variant.id === "delivered" ? "DELIVERED" : "IN TRANSIT"}</p><p style="margin:0 0 16px;font-size:20px;font-weight:bold;">${e(data.carrier)}</p><table role="presentation" width="100%" cellpadding="0" cellspacing="0">${detail("Tracking", data.tracking)}${detail("Order", data.reference)}</table></div>`,
    security: `<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${detail("Device", data.device)}${detail("Approximate location", data.location)}${detail("Date", data.date)}</table><p style="font-size:12px;color:#57645f;">Location is approximate. Review your account for the latest session details.</p>`,
    summary: `<div style="padding:20px;border:1px solid #dce5e1;background:#f6f8f7;"><p style="margin:0 0 8px;font-size:12px;color:#57645f;">YOUR WORKSPACE</p><p style="margin:0;font-size:20px;font-weight:bold;">${e(data.company)}</p><p style="margin:8px 0 0;font-size:13px;color:#57645f;">Reference ${e(data.reference)}</p></div>`,
  };
  const subject = `${data.company}: ${variant.subject}`.replace(/[\r\n]/g, " ");
  const tone =
    variant.tone === "success"
      ? "UPDATE CONFIRMED"
      : variant.tone === "warning"
        ? "ACCOUNT UPDATE"
        : template.category.toUpperCase();
  const html = `<!doctype html><html lang="en" dir="${dir}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light"><title>${e(subject)}</title></head><body style="margin:0;padding:0;background:#f1f4f2;color:#172c24;font-family:Arial,Helvetica,sans-serif;"><div style="display:none;font-size:1px;color:#f1f4f2;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;">${e(variant.preheader)}</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f4f2;"><tr><td align="center" style="padding:32px 12px;"><!--[if mso]><table role="presentation" width="600" align="center"><tr><td><![endif]--><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;border-collapse:collapse;"><tr><td style="padding:8px 24px 24px;font-size:17px;font-weight:bold;letter-spacing:-0.5px;">✦ ${e(data.company)}</td></tr><tr><td style="background:#fff;border-top:4px solid ${accent};padding:32px 24px;"><p style="margin:0 0 20px;font-size:11px;letter-spacing:2px;font-weight:bold;color:${accent};">${e(tone)}</p><h1 style="margin:0 0 20px;font-size:30px;line-height:1.2;font-weight:bold;letter-spacing:-0.7px;">${e(variant.title)}</h1><p style="font-size:15px;line-height:1.7;margin:0 0 12px;">Hello ${e(data.recipient)},</p><p style="font-size:15px;line-height:1.7;color:#42554c;margin:0 0 24px;">${e(variant.message)}</p>${sections[variant.section]}<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top:28px;"><tr><td bgcolor="${accent}" style="background:${accent};border-radius:6px;"><a target="_blank" rel="noopener noreferrer" href="${e(action)}" style="display:inline-block;padding:15px 22px;color:#fff;text-decoration:none;font-size:14px;font-weight:bold;">${e(variant.action)}</a></td></tr></table><p style="margin:24px 0 0;font-size:13px;line-height:1.6;color:#57645f;">With care,<br>The ${e(data.company)} team</p></td></tr><tr><td style="padding:24px;font-size:12px;line-height:1.7;color:#57645f;"><p style="margin:0 0 8px;">Questions? <a target="_blank" rel="noopener noreferrer" href="${e(support)}" style="color:#235843;text-decoration:underline;">Get in touch</a></p><p style="margin:0;">${e(data.company)} · ${e(data.address)}</p><p style="margin:8px 0 0;">This is an account or order update. No reply is required.</p></td></tr></table><!--[if mso]></td></tr></table><![endif]--></td></tr></table></body></html>`;
  const lines = [
    subject,
    "",
    variant.preheader,
    "",
    `Hello ${data.recipient},`,
    "",
    variant.message,
    "",
  ];
  if (variant.section === "code")
    lines.push(`Your one-time code: ${data.code}`, "Expires in 10 minutes.");
  else if (variant.section === "invoice" || variant.section === "order") {
    lines.push(
      `Reference: ${data.reference}`,
      ...data.items.map(
        (item) =>
          `${item.quantity} × ${item.name}: ${emailAmount(item.quantity * item.unitCents, data.currency)}`,
      ),
      `Total: ${emailAmount(total, data.currency)}`,
    );
  } else if (variant.section === "tracking")
    lines.push(
      `Carrier: ${data.carrier}`,
      `Tracking: ${data.tracking}`,
      `Order: ${data.reference}`,
    );
  else if (variant.section === "security")
    lines.push(
      `Device: ${data.device}`,
      `Approximate location: ${data.location}`,
      `Date: ${data.date}`,
    );
  else if (variant.section === "checklist")
    lines.push(
      "1. Make the workspace your own",
      "2. Bring the right people together",
      "3. Start with one meaningful project",
    );
  else lines.push(`Workspace: ${data.company}`, `Reference: ${data.reference}`);
  lines.push(
    "",
    `${variant.action}: ${action}`,
    "",
    `With care,\nThe ${data.company} team`,
    `Support: ${support}`,
    data.address,
  );
  return {
    subject,
    preheader: variant.preheader,
    html,
    text: lines.join("\n"),
  };
}