All posts
4 min readPigeonify

How to send email from Cloudflare Workers

Workers cannot open SMTP connections at all. How to send email from a Worker using fetch, and how to keep your API key out of the script.

cloudflareworkersserverlesstutorial

Cloudflare Workers run on V8 isolates rather than Node, and they cannot open raw TCP connections to arbitrary hosts. That rules out SMTP entirely — nodemailer will not run, and no amount of polyfilling fixes it.

What a Worker can do is fetch. That is all an email API needs.

Before you start

You need three things, and they take about five minutes:

  1. An account. Sign up free — 5,000 emails a month, no card.
  2. A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
  3. An API key. Create one under API Keys. Treat it like a password: it can send mail as you.

Put the key in your environment rather than your source. Workers reads it from there in every example below.

Storing the key

Never put it in wrangler.toml — that file goes in your repository. Use a secret:

npx wrangler secret put PIGEONIFY_API_KEY

It arrives on the env argument, not on process.env, which does not exist in a Worker.

Sending

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

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

    if (!email?.includes("@")) {
      return Response.json({ error: "Valid email required" }, { status: 400 })
    }

    const response = await fetch("https://www.pigeonify.com/api/v1/send", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": env.PIGEONIFY_API_KEY,
      },
      body: JSON.stringify({
        from: "forms@yourdomain.com",
        to: "you@yourdomain.com",
        replyTo: email,
        subject: "New form submission",
        html: `<p>${escapeHtml(message)}</p>`,
      }),
    })

    if (!response.ok) {
      const error = await response.json()
      console.error("send failed", error)
      return Response.json({ error: "Could not send" }, { status: 502 })
    }

    return Response.json({ ok: true })
  },
}

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

from is your verified domain and replyTo is the visitor's address. Sending from a visitor's address fails SPF and gets filtered — this is the single most common reason Worker-backed contact forms stop arriving.

Returning before the send finishes

A Worker is killed once its response is returned, unless you say otherwise. ctx.waitUntil keeps it alive:

ctx.waitUntil(
  fetch("https://www.pigeonify.com/api/v1/send", { /* ... */ })
    .then((r) => (r.ok ? null : r.json().then((e) => console.error("send failed", e))))
    .catch((e) => console.error("send threw", e)),
)

return Response.json({ ok: true })

The caller gets an instant response and the send completes in the background. The trade-off is that you can no longer tell them it failed — fine for a contact form, wrong for a password reset, where the user needs to know.

Rate limiting

A public Worker that sends email is an open relay unless you limit it. The cheapest version uses the KV namespace you probably already have:

const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"
const key = `rate:${ip}`
const count = Number((await env.RATE_LIMIT.get(key)) ?? 0)

if (count >= 5) {
  return Response.json({ error: "Too many requests" }, { status: 429 })
}

await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 })

KV is eventually consistent, so this leaks a little under concurrency. For a contact form that is fine. For anything that costs money, use Durable Objects.

Cloudflare Email Workers are a different thing

Cloudflare has an Email Routing feature with a send_email binding. It is for receiving mail and replying to it, and it can only send to addresses you have verified with Cloudflare — it is not a general sending service. If you need to email arbitrary customers, use an API.

What to get right before production

Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.

Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.

Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.

Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.

Where to go next

If you are weighing providers, we publish honest comparisons that say where the other product is better.

Send email that actually arrives

Authentication, suppression and bounce handling built in. Start free, no card required.

Start free