Email intake
Start orders from email with Cloudflare Email Routing — mailboxes, who may send, what becomes the order, and setup.
Customers can start an order by emailing a purchase order. Cloudflare Email Routing delivers mail
for the configured addresses to the engine Worker’s email() entrypoint
(apps/engine/src/email.ts), which checks the message, turns it into order input and starts the
mailbox’s flow. Deciding what to accept is pure code in packages/core/src/intake.ts.
Mailboxes
Section titled “Mailboxes”The local part of the recipient picks the flow. Defaults (DEFAULT_INTAKE_MAILBOXES):
| Address | Flow |
|---|---|
orders@ |
tpl-b2b-order |
po@ |
tpl-b2b-order |
purchasing@ |
tpl-purchase-request |
Override them with the engine variable INTAKE_MAILBOXES, a JSON object of local part to flow id,
for example {"orders":"tpl-b2b-order","wholesale":"my-wholesale-flow"}. Matching ignores case and a
+tag (orders+acme@ is orders@). The order type comes from the flow, and the flow’s latest
published version runs.
Who may send
Section titled “Who may send”A message is accepted only when all of these hold. Otherwise it is rejected at SMTP time, so the sender’s mail server returns a bounce with the reason:
| Check | Rejection reason |
|---|---|
| The recipient is a configured mailbox | No mailbox <local>@ here |
The raw message is at most 10 MiB (MAX_INTAKE_BYTES) |
Message too large |
Authentication-Results records dmarc=pass, dkim=pass or spf=pass |
Message failed SPF, DKIM and DMARC |
| The sender is recognised (below) | Sender is not a known customer |
| The mailbox’s flow has a published version | Mailbox is not configured |
A sender is recognised, in this order, by:
- Customer address. The envelope sender equals a
customers.email; the order gets thatcustomerId. - Company domain. The sender’s domain matches exactly one customer’s email domain, unless it is a webmail domain (Gmail, Outlook, Yahoo, iCloud, Proton and similar), which never identifies a customer on its own.
- Allowlist. The engine secret
INTAKE_ALLOWLISTlists addresses or@domains, separated by commas or spaces. These senders start orders without acustomerId; the AI extract node then matches the customer by name from the text.
INTAKE_REQUIRE_AUTH: "false" skips the authentication check. Don’t use it in production: it lets
anyone who forges a customer’s From address start orders.
What becomes the order
Section titled “What becomes the order”| Order field | From the message |
|---|---|
title |
The subject, or Email from <sender> |
customerId |
The recognised customer, if any |
documentText |
From: and Subject: lines, the plain-text body (or the HTML body reduced to text), then each readable attachment under --- Attachment: <filename> ---; capped at 50,000 characters |
meta |
via: "email", from, to, subject, messageId, senderMatch (customer, domain or allowlist) and attachments (the filenames that were read) |
lines start empty: the flow’s ai.extract node reads them from documentText, as it does for an
order whose input carries a document.
Attachments up to 5 MiB are read. Plain text, CSV, TSV, JSON and XML are decoded directly. PDF,
Office documents, HTML and PNG, JPEG or WebP images are converted with Workers AI toMarkdown;
that conversion is skipped when AI_MODE is mock. Anything else is ignored.
The raw message is stored in R2 at orders/<orderId>/inbound/message.eml and listed in the
order’s documents as kind email.
Redelivery
Section titled “Redelivery”The order starts through startOrderOnce with the idempotency key email:<Message-ID>. If Email
Routing delivers the same message again, it maps to the same order, is logged as duplicate, and
the sender gets no second acknowledgement. A different message that reuses a Message-ID is rejected
with Message-ID reused for a different message.
Acknowledgement
Section titled “Acknowledgement”After starting the order, the engine replies to the sender from the mailbox address with the order
reference. Set INTAKE_REPLY to "false" to turn this off. Email Routing only allows a reply to a
message that passed DMARC, so a failed reply is logged and never fails the intake.
The intake log
Section titled “The intake log”Every message is recorded in inbound_emails with status set to accepted, duplicate or
rejected, along with the reason, the order id and the number of attachments read. The Back
office page shows the latest twenty under Email intake, next to the mailbox addresses.
Setting up Email Routing
Section titled “Setting up Email Routing”Email Routing needs the zone’s MX records, so it replaces any other mail provider on that domain.
Use a subdomain (for example orders.example.com) if the apex already receives mail.
- Deploy the engine first. A rule can only point at a Worker that exists.
- In the dashboard under Email → Email Routing, enable it for the zone. This adds Cloudflare’s
MX records, an SPF record (
v=spf1 include:_spf.mx.cloudflare.net ~all) and a DKIM record, and offers to remove conflicting MX records. Through the API it isPOST /zones/<zone_id>/email/routing/dns. - Add a routing rule for each address with the action Send to a Worker →
orderloop-engine. A literal rule matches one address, so add a rule for every address you want to receive, including any+tagvariants. - Set the catch-all to Drop, so mail to other addresses is never accepted.
- Optionally, set the allowlist:
pnpm --filter @orderloop/engine exec wrangler secret put INTAKE_ALLOWLIST.
To test locally without a mail server, call receiveEmail from a test with a fake
ForwardableEmailMessage, as apps/engine/tests/email.test.ts does.