cloudflare / cloudflare/agentic-inbox
Route inbound mail by SMTP envelope recipient, not To: header
- Dominant language
- TypeScript
- Stars
- 7.5k
- Forks
- 964
- PR merge metrics
- No merged PRs in 30d
Description
`workers/index.ts` `receiveEmail` picks the destination mailbox from `parsedEmail.to`, which is the message's `To:` header. For mailing-list traffic the `To:` header is the list address and the subscriber is only in the SMTP envelope, so the lookup misses, `mailboxes/.json` is absent in R2, and the handler logs `Ignoring email for : mailbox does not exist` and returns. Email Routing records `Handled` with `pass`/`Safe`, no exception is thrown, no retry fires, the message is lost. The subscription confirmation arrives normally (its `To:` is the subscriber), so the failure mode only shows up once list posts start flowing.
### Repro
1. Create a mailbox for `you@yourdomain.com`.
2. Subscribe to any list whose posts have a list `To:` (reproduced with `musl`, `kernel-hardening`, `lkrg-users`, `oss-security` on `lists.openwall.com`).
3. Confirm subscription — confirmation lands in the inbox.
4. Wait for list traffic — Activity Log shows `Handled`, inbox stays empty.
### Sites
- `workers/index.ts:352` — `if (!parsedEmail.to?.length …) throw`
- `workers/index.ts:355` — `allRecipients` built from `parsedEmail.to`
- `workers/index.ts:359-364` — `mailboxId` chosen from `allRecipients`
- `workers/index.ts:367` — silent return when mailbox missing
- `workers/index.ts:397` — `recipient: allRecipients.join(", ")` stored in DB
- `workers/app.ts:113` — `email` handler typed as `{ raw: ReadableStream; rawSize: number }`, hiding `event.to`/`event.from`/`event.headers`
### Proposal
Use `event.to` (envelope `RCPT TO`, i.e. what Email Routing matched and what the Activity Log shows as `Custom Address`) as the mailbox id. Keep header parsing for display/threading only.
```ts
async function receiveEmail(
event: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
) {
const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize);
const parsedEmail = await new PostalMime().parse(rawEmail);
const mailboxId = event.to.toLowerCase();
const allowed = ((env.EMAIL_ADDRESSES ?? []) as string[])
.map((a) => a.toLowerCase());
if (allowed.length > 0 && !allowed.includes(mailboxId)) {
console.log(`Ignoring email: ${mailboxId} not in EMAIL_ADDRESSES`);
return;
}
if (!(await env.BUCKET.head(`mailboxes/${mailboxId}.json`))) {
console.log(`Ignoring email for ${mailboxId}: mailbox does not exist`);
return;
}
// …existing storage logic, with `recipient` set from envelope + header
// so list mail still shows the list address for context.
}
```
Also update the handler signature in `workers/app.ts:113` to `ForwardableEmailMessage` so `event.to`/`event.from`/`event.headers` are visible to TS.
### Why
The SMTP envelope is authoritative — it's what Email Routing matched the rule against and the only field that survives BCC, alias expansion, and list distribution. Parsing `To:` is a re-derivation that disagrees with the envelope in the most common "third-party drives mail to me" cases: mailing lists, BCC-only delivery, and forwarders that don't rewrite headers. Right now those all get silently dropped with `Handled` in the Activity Log, which is invisible to operators and unrecoverable.
Happy to send a PR.
Contributor guide
Research direction
Start at workers/index.ts:352-397 to trace how recipients select the mailbox and are stored, then inspect workers/app.ts:113 and the email handler type. Use the issue’s mailing-list reproduction to verify envelope-recipient routing, and confirm that header recipients remain available for display or threading while the correct mailbox receives the message.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100