Skip to main content
Technology & EngineeringEmail Template379 lines

Transactional Templates

Designing and implementing transactional email templates for automated notifications

Quick Summary18 lines
You are an expert in transactional email template design and implementation for automated system-generated notifications.

## Key Points

1. **Account lifecycle**: Welcome, email verification, password reset, account deactivation
2. **Commerce**: Order confirmation, payment receipt, shipping notification, delivery confirmation, refund processed
3. **Security**: Login from new device, two-factor code, password changed, suspicious activity
4. **Notifications**: Comment reply, mention, invitation, shared document
5. **Billing**: Invoice, payment failed, subscription renewal, trial expiration
- Every transactional email should have exactly one primary call to action. Secondary actions (help links, account settings) should be visually subordinate.
- Include all essential information directly in the email. Do not force users to click through to see their order total, tracking number, or other key data.
- Add security context to sensitive emails (password reset, login alerts): IP address, approximate location, device/browser. This helps users detect unauthorized access.
- Use time-limited tokens for action URLs. Display the expiration clearly ("This link expires in 30 minutes").
- Always include a plain-text version. It improves deliverability and serves users on text-only clients.
- Send transactional emails from a separate subdomain and IP from marketing emails (e.g., `notifications@mail.example.com`) to isolate deliverability reputation.
- Include an "If you didn't request this" message on security-related emails.
skilldb get email-template-skills/Transactional TemplatesFull skill: 379 lines
Paste into your CLAUDE.md or agent config

Transactional Email Templates — Email Templates

You are an expert in transactional email template design and implementation for automated system-generated notifications.

Core Philosophy

Overview

Transactional emails are triggered by user actions or system events: welcome messages, password resets, order confirmations, shipping notifications, invoices, and security alerts. Unlike marketing emails, they are expected by the recipient, have high open rates, and must be clear, timely, and functional. Their design prioritizes scannability, trust, and a single call to action.

Core Concepts

Transactional vs. Marketing Emails

AspectTransactionalMarketing
TriggerUser action or system eventScheduled campaign
ConsentImplied by account relationshipRequires explicit opt-in
UnsubscribeNot required (but recommended for non-critical)Legally required (CAN-SPAM, GDPR)
PriorityHigh — user expects itVariable
DesignFunctional, scannableBrand-forward, visual
PersonalizationSpecific data (order ID, amount)Segment-level targeting

Common Transactional Email Types

  1. Account lifecycle: Welcome, email verification, password reset, account deactivation
  2. Commerce: Order confirmation, payment receipt, shipping notification, delivery confirmation, refund processed
  3. Security: Login from new device, two-factor code, password changed, suspicious activity
  4. Notifications: Comment reply, mention, invitation, shared document
  5. Billing: Invoice, payment failed, subscription renewal, trial expiration

Template Data Architecture

Define a clear contract between your application and templates:

// Type-safe template data contracts
interface WelcomeEmailData {
  username: string;
  verificationUrl: string;
  expiresInHours: number;
}

interface OrderConfirmationData {
  orderNumber: string;
  orderDate: string;
  items: Array<{
    name: string;
    quantity: number;
    unitPrice: number;
    imageUrl: string;
  }>;
  subtotal: number;
  shipping: number;
  tax: number;
  total: number;
  shippingAddress: {
    name: string;
    line1: string;
    line2?: string;
    city: string;
    state: string;
    zip: string;
    country: string;
  };
  trackingUrl?: string;
}

interface PasswordResetData {
  resetUrl: string;
  expiresInMinutes: number;
  ipAddress: string;
  userAgent: string;
}

Implementation Patterns

Password Reset Template

<!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.0" />
  <title>Reset Your Password</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f6f9fc; font-family: Helvetica, Arial, sans-serif;">
  <div style="display: none; max-height: 0; overflow: hidden;">
    Reset your password — this link expires in {{expiresInMinutes}} minutes.
  </div>

  <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
    <tr>
      <td style="padding: 40px 16px;">
        <table role="presentation" cellpadding="0" cellspacing="0" border="0"
               width="600" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px;">
          <!-- Logo -->
          <tr>
            <td style="padding: 32px 40px 0;">
              <img src="{{logoUrl}}" alt="Company" width="120" height="32" style="display: block; border: 0;" />
            </td>
          </tr>
          <!-- Content -->
          <tr>
            <td style="padding: 32px 40px;">
              <h1 style="margin: 0 0 16px; font-size: 22px; color: #1a1a1a;">Reset your password</h1>
              <p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #525f7f;">
                We received a request to reset your password. Click the button below to choose a new one. This link expires in <strong>{{expiresInMinutes}} minutes</strong>.
              </p>
              <!-- Bulletproof button -->
              <table role="presentation" cellpadding="0" cellspacing="0" border="0">
                <tr>
                  <td style="border-radius: 4px; background-color: #5469d4;">
                    <a href="{{resetUrl}}"
                       style="display: inline-block; padding: 14px 32px; color: #ffffff; font-size: 16px; font-weight: bold; text-decoration: none; border-radius: 4px;">
                      Reset Password
                    </a>
                  </td>
                </tr>
              </table>
              <p style="margin: 24px 0 0; font-size: 14px; line-height: 22px; color: #8898aa;">
                If you did not request this, you can safely ignore this email. Your password will remain unchanged.
              </p>
            </td>
          </tr>
          <!-- Security info -->
          <tr>
            <td style="padding: 0 40px 32px;">
              <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
                     style="background-color: #f8f9fa; border-radius: 4px;">
                <tr>
                  <td style="padding: 16px;">
                    <p style="margin: 0; font-size: 12px; line-height: 18px; color: #8898aa;">
                      This request came from IP <strong>{{ipAddress}}</strong> using {{userAgent}}.
                    </p>
                  </td>
                </tr>
              </table>
            </td>
          </tr>
          <!-- Footer -->
          <tr>
            <td style="padding: 24px 40px; border-top: 1px solid #e6ebf1;">
              <p style="margin: 0; font-size: 12px; line-height: 18px; color: #8898aa;">
                © 2026 Company Inc. · <a href="{{privacyUrl}}" style="color: #8898aa;">Privacy</a> · <a href="{{supportUrl}}" style="color: #8898aa;">Support</a>
              </p>
            </td>
          </tr>
        </table>
      </td>
    </tr>
  </table>
</body>
</html>

Order Confirmation Template (with Handlebars)

<table role="presentation" cellpadding="0" cellspacing="0" border="0"
       width="600" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
  <!-- Header -->
  <tr>
    <td style="padding: 32px 40px; background-color: #22c55e; color: #ffffff;">
      <h1 style="margin: 0; font-size: 22px;">Order Confirmed ✓</h1>
      <p style="margin: 8px 0 0; font-size: 14px; opacity: 0.9;">
        Order #{{orderNumber}} · {{orderDate}}
      </p>
    </td>
  </tr>

  <!-- Items -->
  <tr>
    <td style="padding: 24px 40px;">
      {{#each items}}
      <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
             style="margin-bottom: 16px;">
        <tr>
          <td width="80" style="vertical-align: top; padding-right: 16px;">
            <img src="{{this.imageUrl}}" alt="{{this.name}}" width="80" height="80"
                 style="display: block; border: 0; border-radius: 4px;" />
          </td>
          <td style="vertical-align: top;">
            <p style="margin: 0; font-size: 16px; font-weight: bold; color: #1a1a1a;">{{this.name}}</p>
            <p style="margin: 4px 0 0; font-size: 14px; color: #525f7f;">
              Qty: {{this.quantity}} · ${{this.unitPrice}} each
            </p>
          </td>
        </tr>
      </table>
      {{/each}}
    </td>
  </tr>

  <!-- Totals -->
  <tr>
    <td style="padding: 0 40px 24px;">
      <table role="table" cellpadding="0" cellspacing="0" border="0" width="100%"
             style="border-top: 1px solid #e6ebf1;">
        <tr>
          <td style="padding: 12px 0; font-size: 14px; color: #525f7f;">Subtotal</td>
          <td style="padding: 12px 0; font-size: 14px; color: #525f7f; text-align: right;">${{subtotal}}</td>
        </tr>
        <tr>
          <td style="padding: 4px 0; font-size: 14px; color: #525f7f;">Shipping</td>
          <td style="padding: 4px 0; font-size: 14px; color: #525f7f; text-align: right;">${{shipping}}</td>
        </tr>
        <tr>
          <td style="padding: 4px 0; font-size: 14px; color: #525f7f;">Tax</td>
          <td style="padding: 4px 0; font-size: 14px; color: #525f7f; text-align: right;">${{tax}}</td>
        </tr>
        <tr>
          <td style="padding: 12px 0; font-size: 16px; font-weight: bold; color: #1a1a1a; border-top: 1px solid #e6ebf1;">Total</td>
          <td style="padding: 12px 0; font-size: 16px; font-weight: bold; color: #1a1a1a; text-align: right; border-top: 1px solid #e6ebf1;">${{total}}</td>
        </tr>
      </table>
    </td>
  </tr>

  <!-- Shipping address -->
  <tr>
    <td style="padding: 0 40px 32px;">
      <h2 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: #8898aa; letter-spacing: 1px;">
        Shipping To
      </h2>
      <p style="margin: 0; font-size: 14px; line-height: 22px; color: #525f7f;">
        {{shippingAddress.name}}<br />
        {{shippingAddress.line1}}<br />
        {{#if shippingAddress.line2}}{{shippingAddress.line2}}<br />{{/if}}
        {{shippingAddress.city}}, {{shippingAddress.state}} {{shippingAddress.zip}}<br />
        {{shippingAddress.country}}
      </p>
    </td>
  </tr>
</table>

Template Rendering Pipeline

import Handlebars from "handlebars";
import juice from "juice";
import { minify } from "html-minifier-terser";
import fs from "fs/promises";

// Register helpers
Handlebars.registerHelper("currency", (value: number) =>
  new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value)
);

Handlebars.registerHelper("dateFormat", (date: string) =>
  new Date(date).toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  })
);

interface RenderOptions {
  templateName: string;
  data: Record<string, unknown>;
  inlineCSS?: boolean;
}

async function renderEmail({ templateName, data, inlineCSS = true }: RenderOptions) {
  const source = await fs.readFile(`./templates/${templateName}.html`, "utf8");
  const template = Handlebars.compile(source);
  let html = template(data);

  if (inlineCSS) {
    html = juice(html);
  }

  html = await minify(html, {
    collapseWhitespace: true,
    removeComments: true,
    minifyCSS: true,
  });

  return html;
}

// Usage
const html = await renderEmail({
  templateName: "order-confirmation",
  data: {
    orderNumber: "ORD-98765",
    orderDate: "2026-03-15",
    items: [
      { name: "Wireless Headphones", quantity: 1, unitPrice: 79.99, imageUrl: "..." },
    ],
    subtotal: 79.99,
    shipping: 5.99,
    tax: 6.88,
    total: 92.86,
    shippingAddress: {
      name: "Alice Johnson",
      line1: "123 Main St",
      city: "Portland",
      state: "OR",
      zip: "97201",
      country: "United States",
    },
  },
});

Multi-Provider Sending Abstraction

interface EmailProvider {
  send(params: {
    to: string;
    from: string;
    subject: string;
    html: string;
    text: string;
  }): Promise<{ id: string }>;
}

class EmailService {
  private primary: EmailProvider;
  private fallback: EmailProvider;

  constructor(primary: EmailProvider, fallback: EmailProvider) {
    this.primary = primary;
    this.fallback = fallback;
  }

  async send(params: { to: string; from: string; subject: string; html: string; text: string }) {
    try {
      return await this.primary.send(params);
    } catch (err) {
      console.error("Primary provider failed, falling back:", err);
      return await this.fallback.send(params);
    }
  }
}

Best Practices

  • Every transactional email should have exactly one primary call to action. Secondary actions (help links, account settings) should be visually subordinate.
  • Include all essential information directly in the email. Do not force users to click through to see their order total, tracking number, or other key data.
  • Add security context to sensitive emails (password reset, login alerts): IP address, approximate location, device/browser. This helps users detect unauthorized access.
  • Use time-limited tokens for action URLs. Display the expiration clearly ("This link expires in 30 minutes").
  • Always include a plain-text version. It improves deliverability and serves users on text-only clients.
  • Send transactional emails from a separate subdomain and IP from marketing emails (e.g., notifications@mail.example.com) to isolate deliverability reputation.
  • Include an "If you didn't request this" message on security-related emails.
  • Log every transactional email sent, including template name, recipient, timestamp, and provider response ID for debugging delivery issues.

Common Pitfalls

  • Mixing marketing content into transactional emails: Adding promotional banners to a password reset email violates CAN-SPAM regulations and erodes trust. Keep transactional emails purely functional.
  • Missing plain-text fallback: Sending HTML-only emails hurts deliverability scores with major providers and fails for text-only clients.
  • Tokens that never expire: Password reset and verification tokens should have a short TTL (15-60 minutes). Long-lived tokens are a security risk.
  • Hardcoded sender addresses: Use environment configuration for from addresses so staging and production environments do not collide.
  • No idempotency guard: If a user clicks "resend verification" multiple times, ensure you do not create duplicate tokens or send excessive emails. Debounce or rate-limit.
  • Unhandled template variables: A missing variable in a Handlebars/Liquid template renders as an empty string, producing broken sentences. Validate data completeness before rendering.
  • Not testing with blocked images: Many corporate clients block images by default. Verify that the email is understandable with all images replaced by their alt text.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add email-template-skills

Get CLI access →