Skip to content

tutorials

Add transactional and lifecycle email to your SaaS in a day

June 3, 2026

#transactional-email#lifecycle-email#saas#cloudflare-workers#tutorial
Add transactional and lifecycle email to your SaaS in a day
Visual: Phosphor Green on okslop

Your product needs to send a receipt, a password reset, a verification code, and a three-email onboarding sequence. The usual answer is three vendors: one for transactional, one for marketing automation, one for the support inbox. Three bills, three copies of your contacts, three places a bug can hide. Here is the whole thing built once, honestly, then the shortcut that collapses the delivery part.

"Add email to your SaaS" sounds like a one-liner: install an SDK, call send. The market is full of drop-ins that promise exactly that, and if you just want to survey them quickly, the tools to add email to your app in an afternoon is the shortlist. But the SDK is the easy part. Email in a real product is four jobs wearing a trench coat. There is the trigger (the user did a thing). There is the template (turn data into HTML). There is the suppression check (don't mail people who left or bounced). And there is delivery (sign it, relay it, track whether it landed). Transactional and lifecycle email are the same four jobs; only the trigger differs.

The trap is solving each job with a separate SaaS, then spending the next year keeping their copies of your user list in sync. Let's build all four properly, see exactly where the work actually is, and then look at doing it as one deploy you own.

What is the difference between transactional and lifecycle email?

Transactional email is triggered by a single user action and is expected by the recipient; lifecycle email is sent based on where a user sits in their journey. A receipt after checkout is transactional. A "your trial ends in two days" nudge is lifecycle. Mechanically they are the same HTTPS send. The differences that matter are the trigger and the rules:

  • Transactional: fired inline from app code, one recipient, no unsubscribe required (it's a service message), must be fast and reliable. Examples: password reset, email verification, receipt, shipping update.
  • Lifecycle / marketing: fired on a schedule or segment, many recipients, requires a working List-Unsubscribe header and consent, can tolerate a few minutes of latency. Examples: onboarding drip, feature announcement, win-back.

The practical consequence: build them on one send path so you have one template store and one suppression list, but send them from two subdomains so marketing volume can't drag down the deliverability of your password resets. More on why that separation matters in separating transactional and marketing sending, and the cost-and-reputation version of the same argument is in reasons to split transactional and marketing email.

Step 1: Authenticate two sending subdomains

Before a single email goes out, publish SPF, DKIM, and DMARC for the domains you'll send from, so receivers accept your mail and you build reputation per stream. Skipping this is why "my app's emails go to spam" is the most common complaint from teams that bolted email on quickly.

Use two subdomains so the streams have independent reputation:

mail.yourdomain.com   → transactional (receipts, resets)
news.yourdomain.com   → marketing / lifecycle (drips, announcements)

Each needs its own DKIM key and its own DMARC policy. A minimal, correct DNS set for the transactional subdomain looks like this:

; SPF: authorize your delivery layer to send for this subdomain
mail.yourdomain.com.        TXT  "v=spf1 include:_spf.your-delivery.com -all"

; DKIM: public key for the selector your signer uses
s1._domainkey.mail.yourdomain.com.  TXT  "v=DKIM1; k=rsa; p=MIGfMA0GCS..."

; DMARC: start at quarantine, move to reject once aligned
_dmarc.mail.yourdomain.com. TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"

Get this wrong and nothing else in this article matters, because your mail never reaches the inbox. The full walkthrough, including the alignment gotchas that silently fail DMARC, is in the SPF, DKIM, and DMARC setup guide. Verify it actually passes at the receiver before moving on with the authentication checker steps.

Step 2: Build a template layer with variables

Store named templates with placeholders and a single render function, so your receipts and onboarding emails aren't hardcoded HTML strings scattered through controllers. The moment you have more than two emails, inlining HTML becomes unmaintainable: you can't preview them, you can't reuse a header, and a copy fix means a code deploy.

A minimal store keyed by template name, with {{variable}} substitution, gets you 90% of the value:

// templates.ts
type Template = { subject: string; html: string; text: string };

const TEMPLATES: Record<string, Template> = {
  "password-reset": {
    subject: "Reset your {{appName}} password",
    html: `<p>Hi {{firstName}},</p>
           <p>Reset your password: <a href="{{resetUrl}}">{{resetUrl}}</a></p>
           <p>This link expires in 1 hour.</p>`,
    text: "Hi {{firstName}}, reset your password: {{resetUrl}} (expires in 1 hour).",
  },
  "onboarding-day-3": {
    subject: "Getting the most out of {{appName}}",
    html: `<p>Hi {{firstName}},</p>
           <p>You created your account 3 days ago. Here's the one feature most teams miss...</p>
           <p><a href="{{unsubscribeUrl}}">Unsubscribe</a></p>`,
    text: "Hi {{firstName}}, here's the one feature most teams miss... Unsubscribe: {{unsubscribeUrl}}",
  },
};

export function render(name: string, vars: Record<string, string>): Template {
  const tpl = TEMPLATES[name];
  if (!tpl) throw new Error(`unknown_template:${name}`);
  const fill = (s: string) =>
    s.replace(/\{\{(\w+)\}\}/g, (_, key) => {
      if (!(key in vars)) throw new Error(`missing_var:${key}`);
      return escapeHtml(vars[key]);
    });
  return { subject: fill(tpl.subject), html: fill(tpl.html), text: fill(tpl.text) };
}

function escapeHtml(s: string): string {
  return s.replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!),
  );
}

Two things people skip and regret. First, escape interpolated values (note escapeHtml above); a user's name with a < in it will otherwise break your markup or open an injection hole. Second, fail loudly on a missing variable rather than rendering {{firstName}} literally into someone's inbox. The missing_var throw above turns a silent embarrassment into a caught error.

If you want React-based templates instead of strings, that's a real option on the edge, with caveats, covered in rendering React Email on Workers.

Step 3: Add a suppression check before every send

Maintain a suppression list of hard bounces and unsubscribes, and check every recipient against it before sending, transactional or lifecycle. This is the single highest-leverage thing you can do for deliverability, and it's the one most "just add email" tutorials omit entirely. Mailbox providers track how often you mail addresses that don't exist or that opted out, and they downgrade senders who keep doing it.

One table is enough:

CREATE TABLE suppressions (
  email      TEXT PRIMARY KEY,      -- store lowercased
  reason     TEXT NOT NULL,         -- 'hard_bounce' | 'unsubscribe' | 'complaint'
  created_at INTEGER NOT NULL
);
// suppression.ts
export async function isSuppressed(db: D1Database, email: string): Promise<boolean> {
  const row = await db
    .prepare("SELECT 1 FROM suppressions WHERE email = ?")
    .bind(email.trim().toLowerCase())
    .first();
  return row != null;
}

export async function suppress(db: D1Database, email: string, reason: string) {
  await db
    .prepare(
      "INSERT OR IGNORE INTO suppressions (email, reason, created_at) VALUES (?, ?, ?)",
    )
    .bind(email.trim().toLowerCase(), reason, Date.now())
    .run();
}

The nuance: transactional mail should respect hard bounces and complaints, but not marketing unsubscribes. Someone who unsubscribed from your newsletter still needs their password-reset email. So suppression checks are reason-aware: block hard_bounce and complaint for everything, block unsubscribe only for lifecycle sends. Where bounces come from and how to classify them is its own topic, handled in handling bounces and suppression.

Step 4: Wire the transactional send path

Expose one send function your app calls on user actions. It renders the template, checks suppression, and hands the message to a delivery layer over HTTPS. Here is where the Cloudflare constraint bites, so let's hit it head-on.

You might reach for a Node SMTP client. On Workers it does not work:

// This does NOT work on Cloudflare Workers.
import nodemailer from "nodemailer";
const t = nodemailer.createTransport({ host: "smtp.relay.com", port: 25 });
await t.sendMail({ from, to, subject, html });
✘ [ERROR] The Workers runtime blocks raw outbound TCP on port 25.
  nodemailer cannot open an SMTP socket here; this transport never connects.

Why can't a Worker speak SMTP? Because the Workers runtime does not allow raw outbound connections on port 25. Outbound is HTTP/HTTPS via fetch. So delivery is an HTTPS call to a layer that signs the message with DKIM and relays it onward. The full reasoning is in why edge email needs an HTTPS sender. The send function becomes:

// send.ts
import { render } from "./templates";
import { isSuppressed } from "./suppression";

type SendArgs = {
  to: string;
  template: string;
  vars: Record<string, string>;
  stream: "transactional" | "marketing";
};

export async function sendEmail(env: Env, args: SendArgs): Promise<{ id: string }> {
  // 1. suppression (reason-aware in a real build)
  if (await isSuppressed(env.DB, args.to)) {
    return { id: "suppressed" };
  }

  // 2. render
  const { subject, html, text } = render(args.template, args.vars);

  // 3. choose the right from-subdomain per stream
  const from =
    args.stream === "transactional"
      ? "noreply@mail.yourdomain.com"
      : "hello@news.yourdomain.com";

  // 4. hand off through the binding or HTTPS (direct-to-MX port 25 is blocked)
  const res = await fetch(env.DELIVERY_URL, {
    method: "POST",
    headers: {
      authorization: `Bearer ${env.DELIVERY_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ to: args.to, from, subject, html, text }),
  });

  if (!res.ok) throw new Error(`delivery_failed:${res.status}`);
  return (await res.json()) as { id: string };
}

Now a password reset is one call from your auth code:

await sendEmail(env, {
  to: user.email,
  template: "password-reset",
  vars: { appName: "Acme", firstName: user.firstName, resetUrl },
  stream: "transactional",
});

That is transactional email, done properly: rendered, suppression-checked, sent on the right stream, delivered over HTTPS. The part this article still hand-waves is everything behind DELIVERY_URL: DKIM signing, the off-platform SMTP conversation, bounce capture, retries. That's the genuinely hard 80%, and we'll come back to it.

Step 5: Add lifecycle email on top of the same path

Lifecycle email is just the transactional send function fired on a schedule against a segment of users, so reuse it rather than bolting on a second stack. This is the realization that turns "a day of work" into a reality: you already built the engine in step 4. Lifecycle is a query plus a loop.

Use a scheduled trigger (a cron) to find users at a journey stage and send them the matching template:

// scheduled.ts (runs on a cron, e.g. once an hour)
export default {
  async scheduled(_event: ScheduledController, env: Env) {
    // Users who signed up ~3 days ago and haven't gotten the day-3 email
    const due = await env.DB.prepare(
      `SELECT id, email, first_name FROM users
       WHERE created_at BETWEEN ? AND ?
         AND onboarding_day3_sent_at IS NULL`,
    )
      .bind(Date.now() - 4 * 864e5, Date.now() - 3 * 864e5)
      .all<{ id: string; email: string; first_name: string }>();

    for (const u of due.results) {
      await sendEmail(env, {
        to: u.email,
        template: "onboarding-day-3",
        vars: {
          appName: "Acme",
          firstName: u.first_name,
          unsubscribeUrl: `https://yourdomain.com/u/${u.id}`,
        },
        stream: "marketing",
      });
      // mark as sent so the next cron tick doesn't resend
      await env.DB.prepare(
        "UPDATE users SET onboarding_day3_sent_at = ? WHERE id = ?",
      )
        .bind(Date.now(), u.id)
        .run();
    }
  },
};

Three things make this production-grade rather than a foot-gun. First, the idempotency marker (onboarding_day3_sent_at): without it, the next cron tick re-sends to the same users. Second, the List-Unsubscribe header: lifecycle mail legally and practically requires a working one-click unsubscribe, covered in implementing one-click unsubscribe. Third, batching: looping await over ten thousand users in one cron invocation will blow your CPU budget, so fan the work out, which is its own pattern in sending email reliably at volume with Queues and scheduled digests with Cron Triggers.

That's the whole product: transactional sends inline, lifecycle sends on a schedule, one template store, one suppression list, two clean streams.

The multi-vendor version (and why it hurts)

If you don't build the above, the default path is to buy it in pieces, and the pieces don't fit:

  • A transactional API (one SaaS) holds your send logs and one copy of your users.
  • A marketing-automation tool (a second SaaS) holds your lifecycle sequences and a second copy of your users, perpetually out of sync with the first.
  • A shared inbox (a third SaaS) holds replies and support threads, with a third contact list.

Now every unsubscribe has to propagate across three systems or you mail someone who opted out. Every contact update is a sync job. Every audit is three subprocessors to justify. And each tool prices on a different axis: per-email here, per-contact there, per-seat for the inbox, so your bill grows on three curves at once. The per-contact one is especially nasty as your list grows; that math is in the cost of transactional email at volume.

The multi-vendor version is faster today (sign up, paste an API key) and slower every day after, because integration debt compounds.

The shortcut: one deploy on your own Cloudflare account

Everything above is the correct architecture, and understanding it is worth the read. But notice that the four jobs (trigger, template, suppression, delivery) plus lifecycle scheduling plus the human inbox are one system, and we only split them across vendors because no single owned stack existed. That's the gap Tinbox fills: it deploys the whole thing into your own Cloudflare account in one command.

npx tinbox init yourdomain.com

That scaffolds the delivery layer (DKIM signing, the off-platform SMTP relay, bounce capture, suppression), the lifecycle/newsletter tool, and a human webmail inbox, all on your D1, R2, and Queues. Your transactional send shrinks to one call with no third-party SaaS in the path and no per-email pricing:

import { tinbox } from "tinbox/cf";

// transactional: fired on a user action
await tinbox(env).send({
  to: user.email,
  from: "noreply@yourdomain.com",
  template: "password-reset",
  vars: { firstName: user.firstName, resetUrl },
});

Because it runs on Workers, it still relays over HTTPS rather than opening port 25, and because it's on your account, the suppression list, the send logs, and the inbox are all one database you can query directly. One subprocessor (Cloudflare), one bill, one copy of your contacts.

Two honest caveats so you can plan around them: each deploy serves one brand and domain today (you run many tenants by deploying once per domain on the same Cloudflare account; multi-domain-per-instance is designed but not yet built), and some runtime email routing is gated behind your own committed config rather than fully dynamic. For most SaaS products sending from a single domain, that's exactly the shape you want anyway.

If you're weighing owning this against a hosted API, here's an honest comparison, and if you're starting from "how do I send anything at all from Cloudflare," begin with sending email from a Worker.

Deploy this yourself: npx tinbox init yourdomain.com. The transactional sender, lifecycle scheduling, suppression, and inbox setup are all in the Tinbox docs.

Frequently asked questions

How do I add transactional and lifecycle email to my SaaS product?

Wire four pieces: a trigger in your app code, a template layer with variable substitution, a suppression check so you never mail unsubscribed or bounced addresses, and a delivery layer that signs (DKIM) and relays the message. Transactional email fires on a user action (a receipt after checkout). Lifecycle email is the same send mechanism fired on a schedule or event (a day-3 onboarding nudge). Build them on one send path so you maintain one template store and one suppression list, not two stacks.

What is the difference between transactional and lifecycle email?

Transactional email is triggered by a single user action and is expected: password resets, receipts, verification codes, shipping notices. Lifecycle email is sent based on where a user is in their journey: onboarding sequences, trial-ending nudges, win-back campaigns. Mechanically they are the same HTTPS send; the difference is the trigger (user action versus a schedule or segment) and the consent and unsubscribe rules that apply to bulk lifecycle mail.

Can I run transactional and lifecycle email through the same provider?

You can, but keep the sending streams separate so marketing volume cannot drag down the deliverability of password resets. The common pattern is a shared codebase and template store with two from-subdomains: one for transactional (mail.yourdomain.com) and one for marketing (news.yourdomain.com), each with its own DKIM key and reputation. That gives you one stack to maintain and isolated reputation per stream.

Why can't a Cloudflare Worker send email directly over SMTP?

Cloudflare Workers prohibit outbound port 25, so they cannot perform direct-to-MX SMTP delivery. Workers do expose outbound TCP to allowed third-party submission services, while Cloudflare Email Service is available through its native binding or HTTPS API. For this stack, the binding/HTTPS path owns DKIM, policy, and delivery tracking.

Do I need a separate tool for the suppression list?

No, but you must have one. Before any send, transactional or lifecycle, check the recipient against a suppression list of hard bounces and unsubscribes. A single SQLite table keyed by lowercased email address is enough. Skipping this is the fastest way to tank your sending reputation, because mailbox providers punish senders who keep mailing dead or opted-out addresses.