Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,28 @@ validated derived output or a generated fallback.

Raw email MIME payloads live in the `EMAIL_BLOBS` R2 bucket instead of D1.
`email_messages` stores an object key in `raw_mime_key`
(`email-raw:v1:{userId}/{messageId}`), and the legacy inline `raw_mime` column
is kept only for residual rows that have not yet been swept (or when R2 put
fails at write time — the write path falls back to inline storage rather than
losing mail, and never throws from that decision).
(`email-raw:v1:{userId}/{messageId}`). **Durability policy (Stage 4a):** R2 is
required for inbound MIME — `insertEmailMessage` puts the payload to
`EMAIL_BLOBS` before the D1 insert and never writes new inline `raw_mime` rows.
On R2 put failure the insert throws `EmailRawMimeStorageError` (a
`RetryableInboundStorageError`; no D1 row). The inbound Worker refunds the daily
receive charge and rethrows only typed pre-commit failures so Cloudflare Email
Routing retries without burning quota. The durable commit boundary is message +
attachment rows: thread prework, R2 put, and D1 message/attachment storage are
pre-commit; `touchEmailThread` / `received` delivery-event writes are
post-commit and are logged without throwing (retry would duplicate mail). If
attachment insert fails but message cleanup cannot remove the row — or the
residual-row probe itself fails (ambiguous commit state) — the handler
acknowledges the already-created message (logged, non-retry) rather than risking
a duplicate. Outbound messages pass `rawMime: null` and are unaffected. If D1
insert fails after a successful put, the blob is best-effort deleted. Stage 4a
only prevents **new** inline writes — it does not claim residual inline rows are
gone. Dual-read `loadRawMime` and the maintenance/deploy offload sweep stay
until a later column-drop stage, and that stage must wait until deploy logs
verify `remainingInline = 0` (not merely that Stage 4a shipped).

- All reads go through `loadRawMime` in `packages/worker/src/email/repo.ts`,
which prefers the inline payload and otherwise fetches the blob by key.
which prefers residual inline payload and otherwise fetches the blob by key.
Attachment content extraction re-parses the resolved MIME the same way as
before.
- Message deletes claim rows first by setting transitional
Expand Down Expand Up @@ -378,8 +393,10 @@ losing mail, and never throws from that decision).
is HTTP 500. Deploy loops until `complete === true` (it does not treat total
`remainingInline === 0` as a shortcut) and fails if the secret is missing, on
non-2xx / nonzero `failed`, or if `complete` is still false after the attempt
cap. The write-time inline fallback policy and `loadRawMime` dual-read path
stay in place until a later column-drop stage.
cap. Write-time inline fallback is gone (Stage 4a only stops new inline
writes). `loadRawMime` dual-read and this sweep remain; do not treat total
inline as zero until deploy logs verify `remainingInline = 0`, which is the
gate for a later column-drop stage.
- Bucket names: `kody-email-blobs` (production), per-preview
`{worker}-email-blobs` buckets created and cleaned up by
`tools/ci/preview-resources.ts`, and the test env reuses the preview-style
Expand Down
10 changes: 8 additions & 2 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,13 @@ Rules:
a plan so counters reflect real usage the moment a plan is assigned — unless
the caller passes `fallbackLimit`, which caps plan-less users with a
deployment-level backstop (both email sends and receives do this). Counting
attempts rather than successes keeps the limit abuse-resistant.
attempts rather than successes keeps the limit abuse-resistant for permanent
rejects (parse failures, entitlement/quota rejects). Only typed pre-commit
`RetryableInboundStorageError` failures (thread prework, R2 put, D1
message/attachment storage after successful cleanup) refund exactly one
`email_receives_per_day` unit via `refundDailyEntitlement` for the same UTC
day that was charged, so Cloudflare Email Routing retries do not burn the
daily receive quota. Post-commit bookkeeping failures do not refund or retry.
`incrementDailyEntitlementCounter` remains for raw counter writes (tests,
backfills).
- **Boolean allowances** (persistent package services) are modeled as limit `0`
Expand Down Expand Up @@ -245,7 +251,7 @@ The exemplar is job scheduling: `createJob` in
| `persistent_package_services` | `service_start` for services declared `mode: 'persistent'` |
| `repo_sessions` | `repo_open_session` before creating a new session |
| `email_sends_per_day` | `sendOutboundEmail` (atomic `consumeDailyEntitlement`, NULL-plan backstop) |
| `email_receives_per_day` | `handleInboundEmail` (atomic `consumeDailyEntitlement`, NULL-plan fallback) |
| `email_receives_per_day` | `handleInboundEmail` (atomic `consumeDailyEntitlement`, NULL-plan fallback; refund only on `RetryableInboundStorageError`) |
| `stored_email_messages` | `handleInboundEmail` before storage (NULL-plan fallback) |
| `email_message_bytes` | `handleInboundEmail` before quota/parse (per-message raw size, NULL-plan fallback) |
| `secrets` | new-entry branch of `saveSecret` in `packages/worker/src/mcp/secrets/service.ts` |
Expand Down
5 changes: 4 additions & 1 deletion docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ Inbound storage is quota-gated per user:
with a generic "over quota" response to the sender, and the detailed reason is
recorded as a `rejected` delivery event. Oversize mail is rejected before it
consumes any daily receive quota, and mail to unverified accounts (which can
never receive) is rejected without consuming any quota at all.
never receive) is rejected without consuming any quota at all. Transient
storage failures (for example an R2 outage while saving raw MIME) do not keep
the daily receive charge — the attempt is refunded so delivery retries are not
blocked by quota.
- Plan users get their plan's limits; users without a plan get conservative
deployment fallbacks (they are not unlimited for inbound mail).
- Quota, size, and unverified-account rejections store at most five detailed
Expand Down
Loading
Loading