Skip to content
Runtime APIs / Email

Runtime APIs

Email

Send email from a Worker through an SMTP server that you configure, and receive email in its email() handler.

Status: Supported. Needs a service: set ORANGE_EMAIL_SMTP_URL to your SMTP server.

Choose a provider

Set ORANGE_EMAIL_SMTP_URL to your provider’s SMTP server. smtp:// uses STARTTLS when the server offers it. smtps:// uses TLS from the start. URL-encode the user name and password, for example / as %2F, + as %2B, and = as %3D.

ProviderORANGE_EMAIL_SMTP_URLUser name and password
Amazon SESsmtp://<SMTP user name>:<SMTP password>@email-smtp.<region>.amazonaws.com:587SES SMTP credentials for that region. The password is not your AWS secret access key. Use smtps:// with port 465 for TLS from the start. SMTP endpoints
Resendsmtps://resend:<API key>@smtp.resend.com:465resend and your API key. Port 587 with smtp:// also works.
Postmarksmtp://<server API token>:<server API token>@smtp.postmarkapp.com:587The server API token for both, or an SMTP token’s access key and secret key. Postmark has no port 465.
SendGridsmtps://apikey:<API key>@smtp.sendgrid.net:465The word apikey and your API key. Port 587 with smtp:// also works.
Mailgunsmtp://<SMTP login>:<SMTP password>@smtp.mailgun.org:587The SMTP credentials of your sending domain. For an EU domain, use smtp.eu.mailgun.org.
Any other SMTP serversmtp://<user>:<password>@<host>:587Most providers have an SMTP endpoint.

To pick a Postmark message stream, add the X-PM-Message-Stream header to the message.

For a local SMTP server that catches mail, such as Mailpit, see Test with a local SMTP server.

Send email

A Send Email binding sends through an SMTP server that you set. Use the standard Wrangler binding:

{
  "send_email": [{ "name": "EMAIL", "allowed_destination_addresses": ["team@example.com"] }]
}

Then point OpenOrange at your SMTP server with one variable:

VariableValue
ORANGE_EMAIL_SMTP_URLsmtp://user:password@smtp.example.com:587 uses STARTTLS when the server offers it. smtps://user:password@smtp.example.com:465 uses TLS from the start. URL-encode special characters in the user name and password.
# .dev.vars
ORANGE_EMAIL_SMTP_URL="smtps://resend:<API key>@smtp.resend.com:465"

OpenOrange reads the variable from the shell environment, .dev.vars, or .env, like the R2 S3 settings. A value from the shell wins. On a cluster, set it on the runner pool as a secret, with orange deploy --secrets-file. See Choose a provider for the value of each provider.

Both forms of send()

import { EmailMessage } from "cloudflare:email";

// Your own MIME message, sent as it is.
await env.EMAIL.send(new EmailMessage("me@example.com", "team@example.com", rawMime));

// A message object, as in Cloudflare's Email Service. OpenOrange builds the MIME.
const { messageId } = await env.EMAIL.send({
  from: { email: "me@example.com", name: "Me" },
  to: "team@example.com",
  cc: ["lead@example.com"],
  bcc: "archive@example.com",
  replyTo: "help@example.com",
  subject: "Report",
  text: "The report is attached.",
  html: "<p>The report is attached.</p>",
  headers: { "X-Report": "weekly" },
  attachments: [{ content: base64Pdf, filename: "report.pdf", type: "application/pdf", disposition: "attachment" }],
});

Both forms return { messageId }. For an EmailMessage, messageId is its Message-ID header, or "" when it has none. Bcc recipients get the message through the SMTP envelope, so the message carries no Bcc header.

Address limits

As on Cloudflare, the binding refuses a message before it sends anything when an address breaks its limits:

FieldEffect
destination_addressEvery recipient, including Cc and Bcc, must be this address.
allowed_destination_addressesEvery recipient must be in the list.
allowed_sender_addressesThe sender must be in the list.

Errors

SituationWhat send() does
ORANGE_EMAIL_SMTP_URL is not setThrows Send Email binding "EMAIL" is not supported by OpenOrange. Set ORANGE_EMAIL_SMTP_URL to your SMTP server, ...
The SMTP server is down or refuses the messageThrows Sending email through smtp://smtp.example.com:587 failed: .... The message never names the user name or password.
An address breaks a limitThrows, and names the address and the field.
A message object has no from, to, subject, or text/htmlThrows a TypeError.

OpenOrange does not check sender domains or verified destinations, which Cloudflare does. Your SMTP provider does its own checks.

Test with a local SMTP server

A local server that catches mail, such as Mailpit, sends nothing out. Mailpit listens for SMTP on port 1025 and shows the mail at http://127.0.0.1:8025:

ORANGE_EMAIL_SMTP_URL=smtp://127.0.0.1:1025 orange dev

Receive email

A Worker’s email() handler runs when a message is POSTed to /cdn-cgi/handler/email, the route that wrangler dev also serves. Point your email provider’s inbound webhook, or any script that can POST a raw message, at it.

SettingEffect
ORANGE_EMAIL_INBOUND_SECRETRequired by orange serve, which serves the route only when it is set. orange dev serves the route without it, as wrangler dev does, and checks it when it is set.

Send the secret in one of two ways:

  • Authorization: Bearer <secret>.
  • As the Basic auth password, which most providers allow in the webhook URL: https://inbound:<secret>@worker.example.com/cdn-cgi/handler/email.

A wrong or missing secret gets 401 Unauthorized, and email() does not run.

What the route accepts

BodyEnvelope
A raw MIME message, any content type?from=<sender>&to=<recipient> in the URL
multipart/form-data from SendGrid Inbound Parse with POST the raw, full MIME message on: the email fieldThe envelope field, or the URL
multipart/form-data from Mailgun with the raw MIME: the body-mime fieldThe sender and recipient fields, or the URL

Inbound providers

ProviderSet up
SendGrid Inbound ParseSet the destination URL to https://inbound:<secret>@<your host>/cdn-cgi/handler/email, and turn on POST the raw, full MIME message.
Mailgun routesForward to https://inbound:<secret>@<your host>/cdn-cgi/handler/email?format=mime. Mailgun sends body-mime only when the URL ends in mime.
Amazon SES, Postmark, ResendNot directly. They POST JSON, which the route does not read. Use a small relay that gets the raw message and POSTs it with ?from=&to=.
curl -X POST "https://worker.example.com/cdn-cgi/handler/email?from=alice@example.org&to=inbox@example.com" \
  -H "Authorization: Bearer $ORANGE_EMAIL_INBOUND_SECRET" \
  --data-binary @message.eml
ResponseWhen
200 Worker successfully processed emailemail() returned
400No envelope, a message over 25 MiB, a message without a Message-ID, or message.setReject()
401A wrong or missing secret
500email() threw. The body has the error

email() gets a ForwardableEmailMessage with from, to, headers, raw, rawSize, and setReject(). As on Cloudflare, headers and raw have no Bcc header.

forward() and reply()

Both send through ORANGE_EMAIL_SMTP_URL, and both throw when it is not set:

export default {
  async email(message) {
    await message.forward("team@example.com", new Headers({ "X-Seen": "yes" }));
    await message.reply(new EmailMessage(message.to, message.from, replyMime));
  },
};
  • forward(rcptTo, headers) sends the message as it arrived, from the address it was sent to, as a relay does. headers may add only X- headers, as on Cloudflare.
  • reply(message) sends the EmailMessage that you build, including its In-Reply-To header.
  • When the SMTP server is down, both throw Sending email through ... failed, so the POST gets 500 and your provider can retry it.

Compatibility

From the latest conformance run. Scores on the compatibility page; every binding on the status page.

Send Email

Pluggable send_email

Runs on Your SMTP server, with inbound email on /cdn-cgi/handler/email.

send() takes an EmailMessage or a message object, with Cc, Bcc, and attachments, and enforces the address limits. A message POSTed to /cdn-cgi/handler/email runs email(), and forward() and reply() send over SMTP. Email Routing itself is Cloudflare-only, and Orange does not check sender domains.

Works with

Locally: Mailpit

Any SMTP server, set in ORANGE_EMAIL_SMTP_URL. Inbound mail arrives on /cdn-cgi/handler/email.

Recorded results

WorksSend Email (send_email)Sends mail from a Worker. destination_address and the allow lists limit who it can reach.Sends through the SMTP server in ORANGE_EMAIL_SMTP_URL. Both an EmailMessage and the message object work, with Cc, Bcc, and attachments. A recipient outside the address limits is refused before anything is sent. Without the variable, send() throws an error that names it. 4/4 pass
What to do
Set ORANGE_EMAIL_SMTP_URL to your mail provider's SMTP endpoint.
What to plan for
  • You bring the SMTP provider. Orange does not check sender domains or verified destinations. Your provider does its own checks.
Results
  • email: Send Email: an EmailMessage with its own MIME arrives over SMTP Pass
  • email: Send Email: a message object arrives with its Cc, Bcc, and attachment Pass
  • email: Send Email: a recipient outside allowed_destination_addresses is refused, and nothing is sent Pass
  • agentic-inbox: a sent email and a threaded reply are delivered over SMTP through the send_email binding Pass
WorksInbound email: email(), forward(), and reply()Email Routing delivers mail to the email() handler, and message.forward() and message.reply() send it on.A message POSTed to /cdn-cgi/handler/email runs email(): raw MIME, or a SendGrid or Mailgun inbound webhook. forward() and reply() send through ORANGE_EMAIL_SMTP_URL, and setReject() answers 400. With ORANGE_EMAIL_INBOUND_SECRET set, a wrong secret gets 401 and email() does not run. orange serve serves the route only when the secret is set. The evidence here runs on orange dev. 7/7 pass
Why
Email Routing itself, Cloudflare's MX and routing rules, is Cloudflare-only. Orange has no mail server, so something else must receive the mail and POST it.
What to do
Point your mail provider's inbound webhook at /cdn-cgi/handler/email, with the secret.
What to plan for
  • Email Routing is Cloudflare-only. A provider such as SendGrid or Mailgun must receive the mail.
Results
  • email: Inbound email: a POSTed message runs email(), and forward() sends it on over SMTP Pass
  • email: Inbound email: reply() sends the Worker's reply, with In-Reply-To Pass
  • email: Inbound email: setReject() answers 400 Pass
  • email: Inbound email: a wrong secret gets 401, and email() does not run Pass
  • vmail: an inbound email goes through email() into the mailbox, and the mailbox lists it Pass
  • vmail: an email to a mailbox that was never created is rejected Pass
  • agentic-inbox: an inbound email goes through the Worker's email() handler into the inbox Pass

In the feature matrix

Alternativesend_emailSends email from the Worker through Email Routing.With ORANGE_EMAIL_SMTP_URL set to an smtp:// or smtps:// URL, send() delivers through that SMTP server. It takes an EmailMessage with its own MIME, or a message object with from, to, cc, bcc, replyTo, subject, text, html, headers, and attachments, and returns { messageId }. destination_address, allowed_destination_addresses, and allowed_sender_addresses are checked before anything is sent. 7 files
Notes
Without ORANGE_EMAIL_SMTP_URL, the binding is a stub whose calls throw Send Email binding "<NAME>" is not supported by OpenOrange. Set ORANGE_EMAIL_SMTP_URL to your SMTP server, or send email through your provider's HTTP API with fetch(). When the server fails, send() throws an error that names the server without its credentials. Orange does not check sender domains or verified destinations. Incoming email handlers are a handler entry. See docs/modules/email.mdx.
Source
src/config/load-wrangler.ts:934-948packages/runtime-node/src/runner.ts:602-609packages/runtime-node/src/bindings/email/send-email.ts:94-128packages/runtime-node/src/bindings/email/smtp.ts:22-54packages/runtime-node/src/bindings/unsupported.ts:31-35tests/orange-email.test.ts:15-105docs/modules/email.mdx:82-87
Partialemail handlerEmail Routing calls email(message, env, ctx) for inbound mail.A message POSTed to /cdn-cgi/handler/email, the route that wrangler dev serves, runs email(message, env, ctx): raw MIME with ?from=&to= in the URL, or a SendGrid or Mailgun inbound webhook. message.forward() and message.reply() send through ORANGE_EMAIL_SMTP_URL, and setReject() answers 400. 8 files
Notes
Email Routing is Cloudflare-only, so mail reaches the Worker only when your email provider's inbound webhook, or a script, POSTs it to the route. orange serve serves the route only when ORANGE_EMAIL_INBOUND_SECRET is set, and a wrong or missing secret gets 401. orange dev serves it without the secret, and checks the secret when it is set. forward() and reply() throw when ORANGE_EMAIL_SMTP_URL is not set. See docs/modules/email.mdx.
Source
packages/runtime-node/src/runner.ts:1145-1161packages/runtime-node/src/runtime.ts:196-202packages/runtime-node/src/events/email.ts:17-32packages/runtime-node/src/events/email.ts:66-98packages/runtime-node/src/events/email.ts:145-170packages/runtime-node/src/events/email.ts:172-256tests/email-handler.test.ts:11-58tests/orange-email.test.ts:106-160