Runtime APIs
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.
| Provider | ORANGE_EMAIL_SMTP_URL | User name and password |
|---|---|---|
| Amazon SES | smtp://<SMTP user name>:<SMTP password>@email-smtp.<region>.amazonaws.com:587 | SES 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 |
| Resend | smtps://resend:<API key>@smtp.resend.com:465 | resend and your API key. Port 587 with smtp:// also works. |
| Postmark | smtp://<server API token>:<server API token>@smtp.postmarkapp.com:587 | The server API token for both, or an SMTP token’s access key and secret key. Postmark has no port 465. |
| SendGrid | smtps://apikey:<API key>@smtp.sendgrid.net:465 | The word apikey and your API key. Port 587 with smtp:// also works. |
| Mailgun | smtp://<SMTP login>:<SMTP password>@smtp.mailgun.org:587 | The SMTP credentials of your sending domain. For an EU domain, use smtp.eu.mailgun.org. |
| Any other SMTP server | smtp://<user>:<password>@<host>:587 | Most 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:
| Variable | Value |
|---|---|
ORANGE_EMAIL_SMTP_URL | smtp://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:
| Field | Effect |
|---|---|
destination_address | Every recipient, including Cc and Bcc, must be this address. |
allowed_destination_addresses | Every recipient must be in the list. |
allowed_sender_addresses | The sender must be in the list. |
Errors
| Situation | What send() does |
|---|---|
ORANGE_EMAIL_SMTP_URL is not set | Throws 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 message | Throws Sending email through smtp://smtp.example.com:587 failed: .... The message never names the user name or password. |
| An address breaks a limit | Throws, and names the address and the field. |
A message object has no from, to, subject, or text/html | Throws 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.
| Setting | Effect |
|---|---|
ORANGE_EMAIL_INBOUND_SECRET | Required 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
| Body | Envelope |
|---|---|
| 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 field | The envelope field, or the URL |
multipart/form-data from Mailgun with the raw MIME: the body-mime field | The sender and recipient fields, or the URL |
Inbound providers
| Provider | Set up |
|---|---|
| SendGrid Inbound Parse | Set the destination URL to https://inbound:<secret>@<your host>/cdn-cgi/handler/email, and turn on POST the raw, full MIME message. |
| Mailgun routes | Forward 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, Resend | Not 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
| Response | When |
|---|---|
200 Worker successfully processed email | email() returned |
400 | No envelope, a message over 25 MiB, a message without a Message-ID, or message.setReject() |
401 | A wrong or missing secret |
500 | email() 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.headersmay add onlyX-headers, as on Cloudflare.reply(message)sends theEmailMessagethat you build, including itsIn-Reply-Toheader.- When the SMTP server is down, both throw
Sending email through ... failed, so the POST gets500and your provider can retry it.
Compatibility
From the latest conformance run. Scores on the compatibility page; every binding on the status page.
Send Email
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.
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_URLto 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