← All writing
Cloudflare Serverless TypeScript Backend

How to Send Transactional Emails with Cloudflare Email Service

Sending transactional emails—welcome messages, verification links, password resets, background job notifications, and invoices—is an essential requirement for almost any modern web app.

For years, the standard approach on serverless and edge platforms was reaching for external email SaaS providers. You would create an account on Resend, Postmark, or SendGrid, create an API token, paste it into your environment variables, and invoke an external HTTP endpoint on every send.

With Cloudflare Email Service (specifically Email Sending), you can send transactional emails natively right inside your Cloudflare Workers using a direct binding:

await env.EMAIL.send({
  to: { email: 'user@example.com', name: 'Alex' },
  from: { email: 'no-reply@yourdomain.com', name: 'My App' },
  subject: 'Welcome to our platform!',
  html: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
  text: 'Welcome! Thanks for joining us.',
});

No third-party SDK dependencies, no API secrets to leak or rotate, and no extra network hop to a third-party cloud.

Here’s how it compares to the alternatives, how domain onboarding works, and how to structure a clean, production-ready email architecture based on real-world implementations.

Cloudflare Email Service vs. Alternatives

Before writing code, let’s look at where Cloudflare Email Service fits in against the popular options: Resend, Postmark, SendGrid, and Amazon SES.

Feature / Consideration Cloudflare Email Service Resend Postmark SendGrid Amazon SES
Integration Model Native Worker Binding (env.EMAIL) or REST API REST API / Node SDK REST API / SMTP REST API / SMTP REST API / SMTP / AWS SDK
API Keys on Workers None required (IAM Binding) ⚠️ Required (RESEND_API_KEY) ⚠️ Required (Server Token) ⚠️ Required (SENDGRID_API_KEY) ⚠️ AWS IAM Credentials
DNS Configuration ✅ 1-click in Cloudflare DNS Manual DNS records Manual DNS records Manual DNS records Manual DNS / Route53
Edge Latency Internal edge routing External HTTPS call External HTTPS call External HTTPS call External HTTPS call
Billing / Platform Integrated into Cloudflare Workers plan Separate SaaS subscription Separate SaaS subscription Separate SaaS subscription AWS monthly invoice
Primary Use Case Outbound transactional & edge alerts Transactional & marketing broadcast Premium transactional delivery High-volume marketing/transactional Low-cost enterprise volume

1. Resend

Resend brought a modern developer experience and React Email templates to the space. It’s slick and intuitive. But on Cloudflare Workers, using Resend means managing another external account, paying a separate subscription, storing RESEND_API_KEY in your worker secrets, and making an outbound HTTPS request to Resend’s servers on every email.

2. Postmark

Postmark remains the gold standard for transactional deliverability and in-depth delivery analytics. But it comes with premium pricing that ramps up quickly, plus the operational overhead of third-party token management and vendor lock-in.

3. SendGrid

Twilio SendGrid is the industry veteran. It’s dependable at huge legacy volumes, but its dashboard and configuration feel clunky, and shared IP reputation issues are a recurring headache.

4. Amazon SES

Amazon Simple Email Service (SES) is unmatched in raw price-per-email ($0.10 per 1,000 emails). But it comes with AWS IAM permissions complexity, initial sandbox restriction friction, manual DKIM/SPF verification loops across separate dashboards, and heavyweight SDK dependencies.

Why Cloudflare Email Service Wins for Workers

If your application or backend is already deployed on Cloudflare Workers or Pages:

  • Zero API Secrets: The send_email binding authenticates automatically within Cloudflare’s runtime infrastructure.
  • Zero External Network Latency: Cloudflare routes the email dispatch through its internal backbone without performing an external HTTPS handshake to a third-party vendor.
  • Instant DNS Configuration: Since your domain is already on Cloudflare DNS, Cloudflare automatically provisions and validates the SPF, DKIM, DMARC, and bounce records for you.

Email Sending vs. Email Routing

Cloudflare bundles two distinct capabilities under the Email Service umbrella:

  1. Email Sending (Outbound): Lets your application send outbound transactional emails (welcome emails, receipts, magic links, alerts).
  2. Email Routing (Inbound): Intercepts inbound emails sent to your custom domain, forwarding them to verified email addresses or triggering an email(message, env, ctx) handler in a Worker to process incoming messages programmatically.

In this guide, we focus on Email Sending.

Step 1: Onboard Your Sending Domain

Your domain must be active on Cloudflare DNS.

You can onboard your domain directly from the Cloudflare Dashboard:

  1. Navigate to Compute & AI > Email Service > Email Sending.
  2. Click Onboard Domain and select your zone.

Or you can run the Wrangler CLI command in your terminal:

npx wrangler email sending enable updates.yourdomain.com

Cloudflare automatically generates and applies the necessary DNS records:

  • SPF record (v=spf1 include:_spf.mx.cloudflare.net ~all)
  • DKIM records for cryptographic domain signing
  • DMARC record (v=DMARC1; p=reject;)
  • MX records on the cf-bounce subdomain for bounce tracking

Verification is usually instant or takes just a few minutes.

Tip: Send from a subdomain like mail.yourdomain.com or corp.yourdomain.com rather than your apex domain. If it ever picks up bounces or spam complaints, your root domain’s reputation — and its regular website and email traffic — stays untouched. Onboarding differs by capability, though: Email Routing treats a subdomain as part of the zone (add it inline under Routing settings), while Email Sending treats a subdomain as a fully separate sending domain — it goes through the same onboarding flow above, with its own SPF, DKIM, DMARC, and bounce records.

Step 2: Configure wrangler.jsonc

To grant your Worker access to the email capability, add the send_email binding to your wrangler.jsonc (or wrangler.toml):

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-api",
  "main": "./src/index.ts",
  "compatibility_date": "2026-03-03",

  // Cloudflare Email Sending binding
  "send_email": [
    {
      "name": "EMAIL"
    }
  ],

  "vars": {
    "APP_NAME": "My App",
    "FROM_EMAIL": "no-reply@updates.yourdomain.com"
  }
}

Security Tip: If you are writing an internal monitoring worker that should only ever alert administrators, you can restrict recipient destinations directly in wrangler.jsonc:

{
  "send_email": [
    {
      "name": "EMAIL",
      "allowed_destination_addresses": ["admin@yourcompany.com"]
    }
  ]
}

Step 3: Production Code Architecture

Let’s build a modular and maintainable email system in TypeScript, similar to real-world production setups.

1. The Low-Level Helper (email-sender.ts)

Let’s create a reusable helper that formats recipients, attaches a plain text alternative (vital for spam scores and accessibility), and handles local development safely.

// src/lib/email-helpers.ts

export type EmailAddressInput = string | { email: string; name: string };

export function formatAddress(email: string, name?: string): EmailAddressInput {
  const cleanName = name?.trim();
  return cleanName ? { email, name: cleanName } : email;
}

export function htmlToPlainText(html: string): string {
  return html
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

This { email, name } shape is what Cloudflare calls a named recipient — a display name alongside the address. It works identically for to, from, cc, and bcc, and the same pattern holds if you’re calling the REST API ({ address, name }) or sending over SMTP (Display Name <address> in the message headers).

Now, the core sender function:

// src/lib/email-sender.ts
import { formatAddress, htmlToPlainText } from './email-helpers';

export interface SendEmailOptions {
  to: string;
  toName?: string;
  subject: string;
  html: string;
}

export interface AppEnv {
  EMAIL: SendEmail;
  FROM_EMAIL: string;
  APP_NAME: string;
  ENVIRONMENT: string;
}

export async function sendTransactionalEmail(
  env: AppEnv,
  opts: SendEmailOptions,
): Promise<string | undefined> {
  // In local development, avoid accidental real dispatches
  if (env.ENVIRONMENT === 'development') {
    console.log(`[DEV] Skipped sending to ${opts.to} — Subject: "${opts.subject}"`);
    return 'dev-mock-id';
  }

  if (!env.EMAIL) {
    throw new Error('Cloudflare EMAIL binding is not configured in environment');
  }

  const fromEmail = env.FROM_EMAIL;
  if (!fromEmail) {
    throw new Error('FROM_EMAIL variable is missing');
  }

  const response = await env.EMAIL.send({
    to: formatAddress(opts.to, opts.toName),
    from: formatAddress(fromEmail, env.APP_NAME || 'My App'),
    subject: opts.subject,
    html: opts.html,
    text: htmlToPlainText(opts.html),
  });

  console.log(`Email delivered to ${opts.to} (Message ID: ${response.messageId})`);
  return response.messageId;
}

2. High-Level Email Service (email-service.ts)

In real applications, you often have multiple business flows: welcome emails, password resets, verification emails, asynchronous job completions, and invoices. Creating a dedicated EmailService class keeps business logic tidy:

// src/services/email.ts
import { sendTransactionalEmail } from '../lib/email-sender';
import type { AppEnv } from '../lib/email-sender';

export class EmailService {
  constructor(private env: AppEnv) {}

  /** Send email verification link on sign up */
  async sendVerificationEmail(email: string, token: string, userName?: string) {
    const verificationUrl = `https://app.yourdomain.com/verify?token=${token}`;
    const html = `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h2>Verify your email address</h2>
        <p>Hi ${userName || 'there'},</p>
        <p>Thanks for registering. Please click the button below to verify your account:</p>
        <p style="margin: 24px 0;">
          <a href="${verificationUrl}" style="background-color: #2563eb; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;">
            Verify Email
          </a>
        </p>
        <p style="color: #6b7280; font-size: 0.875rem;">If you did not create an account, you can safely ignore this email.</p>
      </div>
    `;

    return sendTransactionalEmail(this.env, {
      to: email,
      toName: userName,
      subject: 'Verify Your Email Address',
      html,
    });
  }

  /** Send notification when a background job (e.g. report generation) finishes */
  async sendJobCompletedEmail(email: string, jobTitle: string, downloadUrl: string) {
    const html = `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h2>Your file is ready! 🎉</h2>
        <p>Your processing task for <strong>${jobTitle}</strong> has finished successfully.</p>
        <p style="margin: 20px 0;">
          <a href="${downloadUrl}" style="background-color: #10b981; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; display: inline-block;">
            Download Result
          </a>
        </p>
      </div>
    `;

    return sendTransactionalEmail(this.env, {
      to: email,
      subject: `Your file for ${jobTitle} is ready`,
      html,
    });
  }
}

Step 4: Local Testing and Development

When running npx wrangler dev:

  • By default, Cloudflare simulates the email binding locally. Outgoing messages are intercepted and printed to the terminal/saved locally rather than actually delivered to real inboxes.
  • If you want local development to send real emails to test inboxes, configure "remote": true in your binding:
{
  "send_email": [
    {
      "name": "EMAIL",
      "remote": true
    }
  ]
}

(Remember to keep remote: true for testing only to prevent test runs or unit tests from consuming your sending quotas).

Error Handling & Important Limits

Wrap your email calls in error handling. Cloudflare returns specific error codes:

try {
  await emailService.sendVerificationEmail('user@example.com', 'tok_123');
} catch (error: any) {
  if (error.code === 'E_SENDER_NOT_VERIFIED') {
    console.error('Domain onboarding DNS verification is still pending.');
  } else if (error.code === 'E_RATE_LIMIT_EXCEEDED') {
    console.error('Daily sending quota exceeded. Retry later.');
  } else {
    console.error('Email send failed:', error.message);
  }
}

Key Service Limits to Know:

  • Recipients: Up to 50 recipients per message across to, cc, and bcc.
  • Message Size: Max 5 MiB per email (including HTML, text, and attachments).
  • Quotas: New accounts start with a conservative daily limit that automatically ramps up based on healthy delivery metrics and bounce rates. Sends to verified destination addresses in Email Routing do not consume the quota.
  • Domains per Zone: Up to 30 domains combined for Email Sending and Email Routing per zone, including the apex domain.
  • Transactional Only: Cloudflare Email Service is designed strictly for transactional emails (alerts, authentication, receipts). For bulk marketing broadcasts and newsletters, continue using specialized tools.

Observability

Every domain onboarded to Email Sending gets delivery analytics with no extra setup:

  • Dashboard: Compute & AI > Email Service > select a domain > Analytics tab, for delivery status, bounce reasons, and DKIM/DMARC/SPF results over time.
  • GraphQL Analytics API: query emailSendingAdaptiveGroups (aggregated counts) or emailSendingAdaptive (per-message events, including messageId and errorCause) under viewer > zones. Metrics are retained for 31 days.

See the metrics and analytics docs for the full GraphQL schema.

Conclusion

If you’re already running Workers or Pages with your domain on Cloudflare DNS, adding the send_email binding takes a few minutes and removes an entire third-party account—and its API keys—from your stack.

Working through a similar problem?

Share the context and I’ll see whether I can help move it forward.

Contact Johan