OquMail
Loading account…
Email API4 min readUpdated

Send Email from a Cloudflare Worker Without SMTP

Workers have no SMTP client, so email goes out over HTTPS: the Worker code, the secret, waitUntil for background sends, and a Cron Trigger daily report.

A Worker cannot open an SMTP session the way a Node server does: there is no mail library in the runtime, and raw TCP sockets are a low-level API you do not want to write an SMTP client on top of. Cloudflare's own Email Routing is for receiving, and its send binding only delivers to addresses you have verified. For mail to customers, the answer is an HTTPS email API called with fetch, which Workers do natively. Here is the Worker, where the key goes, and the two patterns that matter: respond-then-send, and send-on-a-schedule.

Quick answer

Store the key with wrangler secret put OQUMAIL_API_KEY, read it from env inside the Worker, and POST JSON to the send endpoint with to as an array, a subject, and html or text. Use ctx.waitUntil for notifications so the visitor gets a response immediately, and await the send when the user needs to know it worked, such as a login code.

Add the secret

  1. In the OquMail dashboard create an API key on the mailbox you will send from, for example alerts@yourdomain.com, and copy it once.
  2. Run wrangler secret put OQUMAIL_API_KEY and paste the key. It is stored encrypted and exposed to the Worker as env.OQUMAIL_API_KEY.
  3. For local development put OQUMAIL_API_KEY=oqm_live_... in a .dev.vars file next to wrangler.toml, and add .dev.vars to .gitignore.
  4. On Cloudflare Pages, add the same variable under Settings, Environment variables, and encrypt it; Pages Functions read it from env as well.

The Worker: a contact form that emails your team

This handles POST from a form on your site. The response goes back as soon as the input is checked; the send continues in the background with waitUntil:

export default {
  async fetch(request, env, ctx) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }
    const { name, email, message, website } = await request.json();
    if (website) return Response.json({ ok: true }); // honeypot
    if (!email || !message || message.length > 5000) {
      return Response.json({ error: "invalid" }, { status: 400 });
    }

    const send = fetch("https://api.oqumail.com/api/v1/emails", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + env.OQUMAIL_API_KEY,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        to: ["team@yourdomain.com"],
        subject: "Website enquiry from " + name,
        text: "From: " + name + " <" + email + ">\n\n" + message,
        fromName: "Website form"
      })
    }).then(async (res) => {
      if (!res.ok) console.error("send failed", res.status, await res.text());
    });

    ctx.waitUntil(send);
    return Response.json({ ok: true });
  }
};

When to await instead of waitUntil

waitUntil is right for notifications: the visitor does not need to wait for your inbox to accept the message. It is wrong for anything the user is about to act on. A login code, a password reset or an order confirmation should be awaited, so a failed send returns an error the user can see rather than a success page followed by silence. The API answers 202 with an id when the message is queued; log that id with your own record so support can find the send in the delivery log later.

Free business email on your own domain

OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.

Get started free

A Cron Trigger that emails a daily report

Workers can run on a schedule with no request involved. Add a scheduled handler and a cron expression in wrangler.toml, and the same fetch call sends the report:

# wrangler.toml
[triggers]
crons = ["0 8 * * *"]   # every day at 08:00 UTC

# worker.js (add next to the fetch handler)
async scheduled(event, env, ctx) {
  const count = await env.DB.prepare("SELECT count(*) AS n FROM signups WHERE day = date('now','-1 day')").first("n");
  await fetch("https://api.oqumail.com/api/v1/emails", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + env.OQUMAIL_API_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      to: ["founders@yourdomain.com"],
      subject: "Signups yesterday: " + count,
      text: count + " new signups yesterday."
    })
  });
}

Protecting a public form

  • Add Cloudflare Turnstile to the form and verify the token in the Worker with the siteverify endpoint before sending. It is free and invisible for most visitors.
  • Keep the honeypot field above as a second layer; it costs nothing.
  • Rate limit by IP with the Rate Limiting binding or a Durable Object counter, so one script cannot drain your daily send allowance.
  • Never accept the recipient address from the request body on a contact form. The recipient is your team, hard-coded; only signup-style flows send to the address the user typed.

Pitfalls specific to Workers

  • Looking for an SMTP library. There is no nodemailer in the Workers runtime; use the HTTP API.
  • Trying to use the Email Routing send binding for customer mail. It only delivers to destination addresses you verified in the dashboard, which is fine for internal alerts and nothing else.
  • Forgetting the secret on a second environment. wrangler secret put is per environment; a staging Worker without the key gets 401 on every send.
  • Calling the API from the browser with the key in client-side JavaScript. Anyone can read it. The Worker is the server; keep the call there.
  • Sending to as a string. The API expects an array, even for one recipient, and returns 400 otherwise.

Common questions

Does this work in Cloudflare Pages Functions?

Yes. Put the same code in functions/API (a way for your app or website to send email automatically)/contact.js as export async function onRequestPost({ request, env, waitUntil }). The env object carries the variables you set on the Pages project.

Can the Worker set the From address?

No. The From address is the mailbox the key belongs to, which keeps DKIM alignment right. Create one key per sending identity (alerts@, hello@, billing@) and bind each as its own secret. The display name is yours to set with fromName.

How do I see whether a send was delivered?

Every send through the API appears in the delivery log in the OquMail dashboard with the receiving server's response, so a "we never got it" report can be checked against the actual SMTP conversation rather than guessed at.