Add email inbox search, inbound quotas, receive metering, and email_usage_get - #636
Conversation
📝 WalkthroughWalkthroughThis PR adds inbound-email quota enforcement with NULL-plan fallback limits, records ChangesInbound email entitlement, metering, and search
Estimated code review effort: 4 (Complex) | ~75 minutes Sequence Diagram(s)sequenceDiagram
participant Email
participant handleInboundEmail
participant consumeDailyEntitlement
participant APP_DB
participant USAGE_EVENTS
Email->>handleInboundEmail: inbound message
handleInboundEmail->>consumeDailyEntitlement: size / receive / stored checks
consumeDailyEntitlement->>APP_DB: read and upsert counters
consumeDailyEntitlement-->>handleInboundEmail: ok or EntitlementLimitError
handleInboundEmail->>APP_DB: delivery event write
handleInboundEmail->>USAGE_EVENTS: record success or error
sequenceDiagram
participant Caller
participant emailUsageGetCapability
participant EntitlementService
participant APP_DB
Caller->>emailUsageGetCapability: email_usage_get
emailUsageGetCapability->>EntitlementService: read usage counters
EntitlementService->>APP_DB: query daily counters and stored messages
EntitlementService-->>emailUsageGetCapability: counts and limits
emailUsageGetCapability-->>Caller: plan, day, usage entries
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🔎 Preview deployed: https://kody-pr-636.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 565a758. Configure here.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
packages/worker/src/mcp/capabilities/email/email-message-search.ts (1)
27-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse canonical enum value constants instead of re-declaring literals.
directionandprocessing_statusre-declare the value lists (['inbound','outbound'],['stored','sent','failed']) instead of reusingemailDirectionValues/emailProcessingStatusValuesfromemail/types.ts, whichshared.tsalready imports for the output schema in this same domain. If a new status/direction value is added later, this input schema can silently drift out of sync.♻️ Proposed fix
+import { emailDirectionValues, emailProcessingStatusValues } from '`#worker/email/types.ts`' ... - inbox_id: z.string().min(1).optional(), - direction: z.enum(['inbound', 'outbound']).optional(), - processing_status: z.enum(['stored', 'sent', 'failed']).optional(), + inbox_id: z.string().min(1).optional(), + direction: z.enum(emailDirectionValues).optional(), + processing_status: z.enum(emailProcessingStatusValues).optional(),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/mcp/capabilities/email/email-message-search.ts` around lines 27 - 29, The input schema in email-message-search.ts is re-declaring enum literals for direction and processing_status instead of reusing the canonical values from email/types.ts. Update the schema to use emailDirectionValues and emailProcessingStatusValues, matching the pattern already used by shared.ts, so the EmailMessageSearch input stays aligned with the domain types and does not drift when values change.packages/worker/src/email/repo.ts (1)
822-863: 🚀 Performance & Scalability | 🔵 TrivialSubstring search forces a full scan per user.
The leading
%wildcard (needed for substring matching) combined withLOWER(...)wrapping the columns prevents any index from being used onsubject/from_address/envelope_from, so this always does a linear scan over the user's rows (bounded by theuser_idfilter, but unbounded within that). For users with very large mailboxes this could get slow given D1's single-threaded, sequential query execution model. If mailbox sizes are expected to grow large, consider an FTS5 virtual table (if enabled for this D1 instance) or a dedicated search index down the line.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/email/repo.ts` around lines 822 - 863, The substring search in searchEmailMessages currently forces a per-user full scan because the LIKE pattern starts with % and the columns are wrapped in LOWER(...), so no index on subject, from_address, or envelope_from can be used. Update searchEmailMessages to avoid this scan-heavy approach, either by switching to an FTS-based search path (if available for this D1 setup) or by routing message search through a dedicated search index, and keep the existing filters and mapMessageRow result mapping intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/worker/src/email/inbound.ts`:
- Around line 92-98: The inbound path in inboundMessage currently calls
findUserAccountByStableUserId before the receive quota checks, which forces a
linear user scan on attacker-controlled traffic. Move the stable-user-to-account
resolution off this hot path by using a persisted reverse lookup keyed by the
stable user id, and have inboundMessage consume that cached/mapped account data
before any expensive parsing or lookup work.
- Around line 97-112: The inbound email flow currently checks
stored_email_messages with assertWithinEntitlement before
insertEmailMessageWithAttachments, but that read-only check can race and let
concurrent requests exceed the cap. Update the inbound.ts write path to reserve
or consume stored_email_messages atomically, following the pattern used by
consumeDailyEntitlement, so the quota is enforced at the same time as the
insert. Use the existing findUserAccountByStableUserId, assertWithinEntitlement,
and insertEmailMessageWithAttachments flow as the location to replace the
non-atomic check with an atomic reservation/update.
In `@packages/worker/src/entitlements/service.ts`:
- Around line 36-59: The inbound-mail lookup in findUserAccountByStableUserId
still performs a full scan of users and hashes every email, which makes the hot
path O(users). Update the entitlements service to persist the stable user id (or
another indexed key) in users and change findUserAccountByStableUserId to query
that indexed field directly instead of iterating over all rows. Keep the same
return shape and preserve the existing parsePlanName handling, but remove the
reverse-hash scan from the inbound-email path.
---
Nitpick comments:
In `@packages/worker/src/email/repo.ts`:
- Around line 822-863: The substring search in searchEmailMessages currently
forces a per-user full scan because the LIKE pattern starts with % and the
columns are wrapped in LOWER(...), so no index on subject, from_address, or
envelope_from can be used. Update searchEmailMessages to avoid this scan-heavy
approach, either by switching to an FTS-based search path (if available for this
D1 setup) or by routing message search through a dedicated search index, and
keep the existing filters and mapMessageRow result mapping intact.
In `@packages/worker/src/mcp/capabilities/email/email-message-search.ts`:
- Around line 27-29: The input schema in email-message-search.ts is re-declaring
enum literals for direction and processing_status instead of reusing the
canonical values from email/types.ts. Update the schema to use
emailDirectionValues and emailProcessingStatusValues, matching the pattern
already used by shared.ts, so the EmailMessageSearch input stays aligned with
the domain types and does not drift when values change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 542e164a-833c-424e-8e0d-bf4e61c96c83
📒 Files selected for processing (23)
docs/contributing/architecture/entitlements.mddocs/contributing/architecture/usage-metering.mddocs/use/email-primitives.mdpackages/worker/client/routes/admin-usage.tsxpackages/worker/src/app/admin-usage-data.node.test.tspackages/worker/src/app/admin-usage-data.tspackages/worker/src/app/loader-data.tspackages/worker/src/email/inbound-entitlements.workers.test.tspackages/worker/src/email/inbound.tspackages/worker/src/email/inbound.workers.test.tspackages/worker/src/email/repo-search.workers.test.tspackages/worker/src/email/repo.tspackages/worker/src/email/test-fixtures.tspackages/worker/src/entitlements/entitlements.node.test.tspackages/worker/src/entitlements/plans.tspackages/worker/src/entitlements/service.tspackages/worker/src/mcp/capabilities/admin/admin-usage-overview.tspackages/worker/src/mcp/capabilities/email/domain.tspackages/worker/src/mcp/capabilities/email/email-message-search.node.test.tspackages/worker/src/mcp/capabilities/email/email-message-search.tspackages/worker/src/mcp/capabilities/email/email-usage-get.tspackages/worker/src/mcp/capabilities/email/email-usage-get.workers.test.tspackages/worker/src/usage/record-usage.ts
| try { | ||
| const account = await findUserAccountByStableUserId(env.APP_DB, userId) | ||
| await consumeDailyEntitlement({ | ||
| db: env.APP_DB, | ||
| userId, | ||
| email: account?.email, | ||
| resource: 'email_receives_per_day', | ||
| fallbackLimit: nullPlanEmailFallbackLimits.email_receives_per_day, | ||
| }) | ||
| await assertWithinEntitlement({ | ||
| db: env.APP_DB, | ||
| userId, | ||
| email: account?.email, | ||
| resource: 'stored_email_messages', | ||
| fallbackLimit: nullPlanEmailFallbackLimits.stored_email_messages, | ||
| }) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show file size and relevant section in inbound.ts
wc -l packages/worker/src/email/inbound.ts
sed -n '1,220p' packages/worker/src/email/inbound.ts
# Locate entitlement helper implementations/usages
rg -n "assertWithinEntitlement|consumeDailyEntitlement|stored_email_messages|email_receives_per_day" packages -SRepository: kentcdodds/kody
Length of output: 17254
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '260,380p' packages/worker/src/entitlements/service.ts
printf '\n---\n'
sed -n '1,260p' packages/worker/src/email/repo.tsRepository: kentcdodds/kody
Length of output: 12099
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the insert helper definition
rg -n "export async function insertEmailMessageWithAttachments|insertEmailMessageWithAttachments\\(" packages/worker/src/email/repo.ts -n -A 120 -B 20
printf '\n--- schema references ---\n'
rg -n "email_messages|stored_email_messages|processingStatus: 'stored'|COUNT\\(" packages -SRepository: kentcdodds/kody
Length of output: 15645
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '640,720p' packages/worker/src/email/repo.ts
printf '\n---\n'
sed -n '67,120p' packages/worker/src/email/test-schema.ts
printf '\n---\n'
sed -n '78,130p' packages/worker/migrations/0030-email-primitives.sqlRepository: kentcdodds/kody
Length of output: 5939
Reserve stored_email_messages atomically
assertWithinEntitlement only reads the current count, and insertEmailMessageWithAttachments happens later with no quota guard. Concurrent inbound emails for the same user can both pass and then get stored, pushing email_messages over the cap. Use an atomic reservation/update in the write path, similar to consumeDailyEntitlement.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/worker/src/email/inbound.ts` around lines 97 - 112, The inbound
email flow currently checks stored_email_messages with assertWithinEntitlement
before insertEmailMessageWithAttachments, but that read-only check can race and
let concurrent requests exceed the cap. Update the inbound.ts write path to
reserve or consume stored_email_messages atomically, following the pattern used
by consumeDailyEntitlement, so the quota is enforced at the same time as the
insert. Use the existing findUserAccountByStableUserId, assertWithinEntitlement,
and insertEmailMessageWithAttachments flow as the location to replace the
non-atomic check with an atomic reservation/update.
5cd767c to
a3eaa7a
Compare
Inbound storage is now gated by two new entitlement resources — email_receives_per_day (atomic daily counter) and stored_email_messages (row count) — enforced in handleInboundEmail before parsing. Users without a plan get deployment fallback backstops instead of unlimited, since inbound volume is attacker-controlled. The routing layer has no caller context, so findUserAccountByStableUserId reverse-resolves the account email for the plan lookup. Every receive attempt for a routed inbox records an email_received usage event (success on store, error on entitlement rejection or parse failure).
email_message_search does case-insensitive substring matching (with literal LIKE-wildcard escaping) against subject, header From, and envelope sender, with the same filters and limit caps as email_message_list. email_usage_get gives the signed-in user their stored message count, today's send/receive counts, the applicable limits, and their plan name. Both are read-only and user-scoped.
Adds email_received to the admin usage metrics and the two new inbound email entitlement resources (email_receives_per_day daily counter, stored_email_messages consumption) to the admin usage page and the admin_usage_overview capability schemas.
Bugbot caught that readEntitlementConsumption treated a NULL plan as unlimited for every resource, so the admin view contradicted the actually-enforced inbound email fallbacks (and 80% warnings never fired). The two email resources now resolve through resolveEmailResourceLimit like the enforcement path and email_usage_get do.
- Cache stable-user-id reverse lookups per isolate (content-hash mapping can never go stale; hits are re-verified with one point read, deleted accounts fall back to a rescan) so the inbound hot path avoids the users-table scan after the first message. - Reuse emailDirectionValues / emailProcessingStatusValues in the email_message_search input schema instead of re-declared literals. - Document the accepted check-then-insert concurrency window on the stored-message cap and the linear-scan LIKE trade-off (bounded by the stored_email_messages cap; FTS5 if mailboxes outgrow it).
…ection writes 1. email_message_bytes: per-plan raw-size cap enforced in handleInboundEmail before quota/parse work (personal 512 KiB, pro/partner 768 KiB, NULL-plan fallback 512 KiB). The plan values sit well below the requested 5-10 MB because raw MIME is stored inline in the email_messages row next to the extracted bodies and D1 hard-caps rows at 2 MB — larger caps would accept mail that cannot be stored. The cap also drives the parser's raw-MIME ceiling so the two size gates can never disagree. Oversize mail is rejected before consuming any daily receive quota, with the same generic SMTP reason and an owner-visible detailed delivery event; the usage event records the rejected size in bytes. Full maxStorageBytes enforcement stays a separate effort (documented as still-unenforced). 2. Bounded rejection writes: quota/size rejections now go through recordBoundedEmailRejectionEvent — at most 5 detailed rejected events per inbox per UTC day, then a single aggregate daily event row (deterministic id, counter + last reason in detail_json) absorbs the rest, so a rejected flood cannot grow D1 one row per attempt. Parse-failure rejections keep per-attempt events: they are already bounded by the daily receive quota (consumed before parsing) and the detail helps owners debug misbehaving senders. Also exposes max_message_bytes in email_usage_get and states in entitlements/data-storage docs that a persisted users.stable_user_id column (app-level backfill) is required before onboarding external users — the reverse-hash scan must not ship into multi-tenant use.
Main's #637 added a verified-account gate to handleInboundEmail in the same pre-parse region as this branch's quota/size gates. Combined design, cheapest rejection first: one stable-id reverse lookup (findUserAccountByStableUserId now also returns emailVerified, read fresh on both cache paths) feeds the verified gate, the per-message size cap, the daily receive rate, and the stored-message cap. Unverified-account mail is rejected before any counter is touched — an account that can never receive must not accumulate daily receive quota — and its rejections flow through the bounded recorder (same attacker-controlled row-growth shape as over-quota floods) while still being metered as email_received errors for owner visibility. The detailed event keeps main's reason and 'account-verification' phase. email_message_search and email_usage_get now use requireVerifiedEmailAccountUser like every other email capability from #637, with tests for the unverified rejection on both.
a3eaa7a to
f1c2070
Compare

What
Fills the email visibility/quota gaps: users can now search their stored mail, inbound storage is quota-gated (rows and bytes), inbound receives are metered, and users can see where they stand. Updated for Kent's denial-of-wallet review, then rebased onto main after #637/#638/#632 landed, reconciling the verified-email gating with the quota enforcement.
1. Inbox search —
email_message_searchsubject,from_address, andenvelope_from(LIKE wildcards in the query are escaped so they match literally).inbox_id/direction/processing_statusfilters, ordering, and limit caps (max 100, default 25) asemail_message_list; alwaysuserId-scoped. Like every email capability after Require verified account email for MCP and email features #637, it requires a verified account email (requireVerifiedEmailAccountUser).2. Entitlements / quotas (reconciled with #637's verified-email gating)
handleInboundEmailbefore parsing, cheapest rejection first, fed by one stable-id account lookup (findUserAccountByStableUserIdnow also returns the verified-email state, read fresh on both cache paths):Account email is not verified.,account-verification) without consuming any daily receive quota: an account that can never receive must not accumulate usage against its limits.email_message_bytes— per-message raw-size cap: personal 512 KiB, pro/partner 768 KiB, NULL-plan fallback 512 KiB. Checked before the daily counter so oversize mail doesn't burn receive quota. Why not the suggested 5–10 MB: raw MIME is stored inline in theemail_messagesrow next to the extracted bodies, and D1 hard-caps rows at 2 MB — a 5 MB cap would accept mail that cannot physically be stored (the parser's pre-existing 512 KiB raw-MIME ceiling enforced this implicitly; the plan cap now drives that ceiling so the two gates can't disagree).email_receives_per_day— atomicconsumeDailyEntitlement(counts attempts): personal 200, pro 1,000, partner 2,000, NULL-plan fallback 200.stored_email_messages— row count: personal 2,000, pro 10,000, partner 25,000, NULL-plan fallback 2,000 (check-then-insert; documented trade-off).nullPlanEmailFallbackLimits); outbound sends keep their existing NULL-plan-unlimited behavior.rejecteddelivery events per inbox per UTC day plus one aggregate daily event row (deterministic id, total count + last reason/phase), so a rejected flood grows D1 by at most 6 rows per inbox per day. Unverified floods are the same attacker-controlled row-growth shape as over-quota floods, hence bounded too (this tightens Require verified account email for MCP and email features #637's per-attempt event insert). Parse-failure rejections keep per-attempt events: they're bounded by the receive quota (consumed before parsing) and useful for debugging senders.3. Usage metering —
email_receivedUsageEventTyperecorded once per receive attempt after inbox resolution:successon store,erroron unverified-account rejection, size rejection, entitlement rejection, or parse failure.bytesalways carries the raw message size — including for rejected mail — andentityIdis the message id when stored. Mail rejected before inbox resolution (unknown alias) has no owning user and is not metered.4. User-visible usage —
email_usage_getmax_message_bytes.email_receivedmetric and the new resources are wired into the admin usage page andadmin_usage_overview, including effective fallback limits for plan-less users.5. Tests + docs
email_usage_getfor plan/plan-less/unverified users.consumeDailyEntitlementfallback capping,resolveEmailResourceLimit,email_message_bytesenforcement semantics, search capability wiring/auth incl. unverified rejection, admin usage data.docs/use/email-primitives.md,docs/contributing/architecture/entitlements.md,docs/contributing/architecture/usage-metering.md,docs/contributing/architecture/data-storage.md.No migrations needed — the new resources reuse
entitlement_daily_countersand existing tables (the aggregate rejection row reusesemail_delivery_eventswith a deterministic id).Verification
npm run validatefully green locally after the final rebase (format, lint, typecheck, 576 unit tests, Playwright E2E, MCP E2E)./cdn-cgi/handler/email):account-verification), zero quota counters, metered as error. After verifying the account, the same alias stores mail and the receive counter increments.size, no daily quota consumed, usage event with the rejected byte count.Required follow-ups (not in this PR)
users.stable_user_idcolumn with an app-level backfill (SQLite cannot compute SHA-256 in a migration) and replace the reverse-hash users-table scan infindUserAccountByStableUserId(which now also serves Require verified account email for MCP and email features #637's verified-email gate on the inbound path). Documented inentitlements.mdanddata-storage.md.maxStorageBytesenforcement: separate effort; the per-message size cap deliberately does not do storage-bytes accounting.Notes for reviewer
POST /__maintenance/reindex-capabilities) so the new capabilities appear in semanticsearch.f4d10022); merge-base equals main head. The Require verified account email for MCP and email features #637 conflicts ininbound.ts/inbound.workers.test.ts/email-primitives.mdwere reconciled in the dedicated commit "Reconcile verified-email gating with inbound quotas and size caps" — both behaviors preserved, ordering and quota semantics documented there.{username}@heykody.devauto-inbox refactor stays out of scope; enforcement sits at the shared insert path and applies unchanged after that refactor.System recap — extends existing primitives (medium risk)
Mode: recap · Base:
main@f4d10022· Head:f1c2070fClassification: extends — no new primitives; the email, entitlements, and usage-metering primitives gain new behavior and contracts.
Primitives touched
emailemail_message_search,email_usage_getentitlementsconsumeDailyEntitlementfallback, stable-id reverse lookup with verified stateusage-meteringemail_receivedevent type (bytes recorded for rejected mail too)app-uimcp-serverSystem map
Change flow
Invariants
userId-scoped;findUserAccountByStableUserIdonly returns the account whose email hashes to the given stable id.entitlements.md) because inbound volume is attacker-controlled.Summary by CodeRabbit