Skip to content

Route inbound mail by SMTP envelope recipient, not To: header #32

@uxdom

Description

@uxdom

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/<list-address>.json is absent in R2, and the handler logs Ignoring email for <list-address>: 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:352if (!parsedEmail.to?.length …) throw
  • workers/index.ts:355allRecipients built from parsedEmail.to
  • workers/index.ts:359-364mailboxId chosen from allRecipients
  • workers/index.ts:367 — silent return when mailbox missing
  • workers/index.ts:397recipient: allRecipients.join(", ") stored in DB
  • workers/app.ts:113email 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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions