Skip to content

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.

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.

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:

  1. Customer address. The envelope sender equals a customers.email; the order gets that customerId.
  2. 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.
  3. Allowlist. The engine secret INTAKE_ALLOWLIST lists addresses or @domains, separated by commas or spaces. These senders start orders without a customerId; 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.

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.

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.

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.

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.

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.

  1. Deploy the engine first. A rule can only point at a Worker that exists.
  2. 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 is POST /zones/<zone_id>/email/routing/dns.
  3. 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 +tag variants.
  4. Set the catch-all to Drop, so mail to other addresses is never accepted.
  5. 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.