← Back to blog

How to Send Email from Cloudflare Workers (Without an SDK)

PushMail Team··3 min read

Cloudflare Workers run on V8 isolates, not Node.js. That means no require('nodemailer'), no SMTP sockets, no net module. If you've tried to send email from a Worker, you've already hit this wall. The runtime doesn't support it.

The workaround most people reach for is calling SendGrid or Postmark's REST API directly with fetch(). That works, but you end up rebuilding contact management, template rendering, bounce handling, and suppression logic inside your Worker. You wanted to send an email, not build an email platform.

Here's a simpler approach: call PushMail's API from your Worker. One fetch() call, and the email is queued.

The runtime constraint

Workers execute in V8 isolates -- the same JavaScript engine that runs in Chrome. You get standard web APIs: fetch, Request, Response, crypto, TextEncoder. You don't get Node.js APIs.

This rules out every Node.js email library:

  • nodemailer -- requires net and tls modules for SMTP connections
  • @sendgrid/mail -- depends on Node's http module internally
  • aws-sdk (SES) -- Node.js-only, massive bundle size even when tree-shaken

You could polyfill some of these, but then you're fighting the runtime instead of working with it. You need a pure HTTP solution -- just fetch() and a REST endpoint.

Sending a transactional email

PushMail's send endpoint accepts a standard JSON payload over HTTP. Here's a Worker that sends a password reset email:

export default {
  async fetch(request, env) {
    const res = await fetch("https://pushmail.dev/api/v1/send", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.PUSHMAIL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        to: "user@example.com",
        siteId: 1,
        templateId: 42,
        variables: {
          reset_url: "https://yourapp.com/reset?token=abc123",
          name: "Alice",
        },
      }),
    });

    const data = await res.json();
    return Response.json(data);
  },
};

The response comes back immediately with a sendId and status: "queued". The actual template rendering and SendGrid delivery happens asynchronously in PushMail's queue consumer. Your Worker isn't blocked waiting for SMTP handshakes.

If you don't want to use a pre-built template, pass html and subject directly instead of templateId:

body: JSON.stringify({
  to: "user@example.com",
  siteId: 1,
  subject: "Your password reset link",
  html: "<p>Click <a href='{{reset_url}}'>here</a> to reset.</p>",
  variables: { reset_url: "https://yourapp.com/reset?token=abc123" },
})

PushMail substitutes {{variable}} placeholders server-side before sending. You get basic templating without shipping a rendering engine in your Worker bundle.

Storing the API key

Never hardcode API keys in your Worker source. Use wrangler secret put to store them as encrypted environment variables:

wrangler secret put PUSHMAIL_API_KEY

Then access it as env.PUSHMAIL_API_KEY in your Worker. The secret is encrypted at rest and injected at runtime. It never appears in your source code, build output, or version control.

Your wrangler.toml needs no changes -- secrets are stored separately from configuration.

Processing a form submission

A more realistic example: a signup form that creates a contact and sends a welcome email. This Worker handles a POST from your frontend, creates the contact in PushMail, and fires off a welcome message.

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const { email, firstName } = await request.json();

    // 1. Create the contact
    const contactRes = await fetch("https://pushmail.dev/api/v1/contacts", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.PUSHMAIL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        siteId: 1,
        email,
        firstName,
        status: "subscribed",
      }),
    });

    if (!contactRes.ok) {
      const err = await contactRes.json();
      return Response.json({ error: err.error }, { status: contactRes.status });
    }

    const { data: contact } = await contactRes.json();

    // 2. Send the welcome email
    await fetch("https://pushmail.dev/api/v1/send", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.PUSHMAIL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        to: email,
        siteId: 1,
        templateId: 10,
        contactId: contact.id,
        variables: { first_name: firstName },
      }),
    });

    return Response.json({ success: true, contactId: contact.id });
  },
};

Two API calls, both using native fetch(). No npm packages, no build step complexity, no compatibility shims. The Worker stays small and deploys in seconds.

Sequences and campaigns from the same API

The send endpoint handles one-off transactional emails. But the same API gives you access to everything else:

  • Drip sequences -- enroll a contact in a multi-step automated flow via POST /v1/sequences/:id/enrollments
  • Campaigns -- schedule a broadcast to an entire list via POST /v1/campaigns
  • Contact management -- create, update, tag, and organize contacts across lists
  • Email validation -- check an address before you send via POST /v1/validate
  • CSV imports -- upload a contact list for async processing via POST /v1/imports/upload

Every endpoint is plain HTTP. If your runtime has fetch(), you can use it. There's no SDK for any of it, and there doesn't need to be.

Edge-native all the way down

Here's something worth knowing: PushMail itself runs on Cloudflare Workers. The API you're calling from your Worker is handled by another Worker. D1 stores the contacts, templates, and send records. KV caches sessions and MX records. Queues handle async email processing. R2 stores templates and uploaded CSVs.

When your Worker in Frankfurt calls PushMail, the request is routed to a PushMail Worker running in the same Cloudflare data center. There's no cross-region hop to us-east-1. The email is validated, the send record is created, and the message is enqueued -- all at the edge.

For transactional email -- password resets, order confirmations, verification codes -- this latency difference matters. Your API call adds single-digit milliseconds, not hundreds. The entire pipeline starts at whatever Cloudflare PoP is closest to your user.

What you skip building

By calling PushMail instead of wiring up SendGrid directly from your Worker, you skip building:

  • Template storage and {{variable}} rendering
  • Contact deduplication (PushMail upserts on email + site)
  • Bounce and complaint handling (auto-suppression via SendGrid webhooks)
  • Send logging and delivery event tracking
  • Rate limiting and retry logic
  • API key scoping across multiple sites or projects

All of that runs on PushMail's side. Your Worker stays focused on your application logic. The pricing is per email sent: $0.003 for the first 10,000, scaling down to $0.0005 at volume. No monthly platform fee, no per-subscriber charges. You pay for the emails your Worker actually sends.