Skip to content

Add email inbox search, inbound quotas, receive metering, and email_usage_get - #636

Merged
kody-bot merged 7 commits into
mainfrom
cursor/email-inbox-search-quotas-8ab0
Jul 6, 2026
Merged

kody-bot merged 7 commits into
mainfrom
cursor/email-inbox-search-quotas-8ab0

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 6, 2026 •

Copy link
Copy Markdown
Owner

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_search

  • New read-only MCP capability: case-insensitive substring match against subject, from_address, and envelope_from (LIKE wildcards in the query are escaped so they match literally).
  • Same inbox_id / direction / processing_status filters, ordering, and limit caps (max 100, default 25) as email_message_list; always userId-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)

  • All fail-closed gates run in handleInboundEmail before parsing, cheapest rejection first, fed by one stable-id account lookup (findUserAccountByStableUserId now also returns the verified-email state, read fresh on both cache paths):
    1. Verified account (Require verified account email for MCP and email features #637's gate) — unverified or deleted accounts are rejected with main's reason/phase (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.
    2. 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 the email_messages row 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). ⚠️ Practical consequence Kent should weigh: ordinary email with a photo attachment easily exceeds 512 KiB, so legitimate attachment mail will bounce on personal plans until raw MIME moves out of D1 (R2/KV follow-up below) — the caps are a platform-storage constraint, not a product choice.
    3. email_receives_per_day — atomic consumeDailyEntitlement (counts attempts): personal 200, pro 1,000, partner 2,000, NULL-plan fallback 200.
    4. 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).
  • NULL-plan users are not unlimited for inbound email (nullPlanEmailFallbackLimits); outbound sends keep their existing NULL-plan-unlimited behavior.
  • Over-quota/oversize mail is rejected with a generic SMTP reason ("Recipient mailbox is over quota.") so the arbitrary sender never sees plan details; the detailed message is stored for the owner.
  • Bounded rejection writes: quota, size, and unverified-account rejections store at most 5 detailed rejected delivery 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_received

  • New UsageEventType recorded once per receive attempt after inbox resolution: success on store, error on unverified-account rejection, size rejection, entitlement rejection, or parse failure. bytes always carries the raw message size — including for rejected mail — and entityId is 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_get

  • Read-only MCP capability (verified users only) returning plan name, UTC day, stored message count/limit, today's send count/limit, today's receive count/limit, and max_message_bytes.
  • Admin visibility: email_received metric and the new resources are wired into the admin usage page and admin_usage_overview, including effective fallback limits for plan-less users.

5. Tests + docs

  • Workers tests: search SQL behavior, inbound enforcement (receive limit, storage cap, oversize rejection without quota consumption, NULL-plan fallback, under-quota success + metering), unverified-account rejection without quota consumption + bounded events + metering, rejection-event bounding, reverse-lookup caching (incl. fresh verified state), email_usage_get for plan/plan-less/unverified users.
  • Node tests: consumeDailyEntitlement fallback capping, resolveEmailResourceLimit, email_message_bytes enforcement semantics, search capability wiring/auth incl. unverified rejection, admin usage data.
  • Docs updated: 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_counters and existing tables (the aggregate rejection row reuses email_delivery_events with a deterministic id).

Verification

  • npm run validate fully green locally after the final rebase (format, lint, typecheck, 576 unit tests, Playwright E2E, MCP E2E).
  • Manual E2E against the local worker (/cdn-cgi/handler/email):
    • Mail to an unverified account → HTTP 400 "Account email is not verified.", bounded rejection events (detailed + aggregate, phase account-verification), zero quota counters, metered as error. After verifying the account, the same alias stores mail and the receive counter increments.
    • 600 KiB message to a personal-plan inbox → HTTP 400 over-quota, phase size, no daily quota consumed, usage event with the rejected byte count.
    • 8-message flood at the daily receive limit → all rejected; rejected rows capped (5 detailed + 1 aggregate carrying the total); all attempts metered.
    • Under-quota mail still stored normally.

Required follow-ups (not in this PR)

  • Before onboarding external users / design partners (required): persist an indexed users.stable_user_id column with an app-level backfill (SQLite cannot compute SHA-256 in a migration) and replace the reverse-hash users-table scan in findUserAccountByStableUserId (which now also serves Require verified account email for MCP and email features #637's verified-email gate on the inbound path). Documented in entitlements.md and data-storage.md.
  • Per-sender / per-alias throttle beneath the daily quota: a third party who learns one alias can burn the owner's entire daily receive quota (griefing, distinct from denial-of-wallet).
  • Raw MIME out of D1 (R2/KV): required before per-message size caps can rise to attachment-friendly levels (5–10 MB); D1's 2 MB row limit is the binding constraint today.
  • Full maxStorageBytes enforcement: separate effort; the per-message size cap deliberately does not do storage-bytes accounting.
  • FTS5 / search index if mailboxes outgrow the LIKE linear scan (bounded today by the stored-message cap).

Notes for reviewer

  • Post-deploy: reindex capability vectors (POST /__maintenance/reindex-capabilities) so the new capabilities appear in semantic search.
  • Branch rebased onto current main (f4d10022); merge-base equals main head. The Require verified account email for MCP and email features #637 conflicts in inbound.ts / inbound.workers.test.ts / email-primitives.md were reconciled in the dedicated commit "Reconcile verified-email gating with inbound quotas and size caps" — both behaviors preserved, ordering and quota semantics documented there.
  • The {username}@heykody.dev auto-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: f1c2070f

Classification: extends — no new primitives; the email, entitlements, and usage-metering primitives gain new behavior and contracts.

Primitives touched

Primitive Group Impact
email assistant extends — inbound verified/size/rate/storage gating, bounded rejection events, email_message_search, email_usage_get
entitlements auth extends — 3 new resources (incl. per-message size), NULL-plan email fallbacks, consumeDailyEntitlement fallback, stable-id reverse lookup with verified state
usage-metering runtime extends — new email_received event type (bytes recorded for rejected mail too)
app-ui surfaces composes — admin usage page shows the new metric/resources
mcp-server surfaces composes — two new registered email capabilities (verified-user gated)

System map

flowchart LR
	connectorIngress["connector-ingress (email routing)"]:::untouched
	email["email"]:::extended
	entitlements["entitlements"]:::extended
	usageMetering["usage-metering"]:::extended
	d1AppDb["d1-app-db"]:::untouched
	mcpServer["mcp-server"]:::touched
	appUi["app-ui (admin usage)"]:::touched
	connectorIngress --> email
	email --> entitlements --> d1AppDb
	email --> usageMetering --> d1AppDb
	mcpServer --> email
	appUi --> usageMetering
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Change flow

sequenceDiagram
	participant S as Sender
	participant H as handleInboundEmail
	participant E as entitlements
	participant D as D1
	S->>H: routed mail (known alias)
	H->>E: findUserAccountByStableUserId (email, plan, verified)
	alt account unverified
		H-->>S: reject "not verified" (no quota consumed)
		H->>D: bounded rejection events + email_received error
	else verified
		H->>E: size cap (no quota consumed on reject)
		H->>E: consumeDailyEntitlement (receives/day, fallback)
		H->>E: assertWithinEntitlement (stored cap, fallback)
		alt over quota or oversize
			H-->>S: reject "over quota" (generic)
			H->>D: bounded rejection events + email_received error
		else under quota
			H->>D: store message + email_received success
		end
	end
Loading

Invariants

  • Per-user isolation: search, usage reads, and enforcement are all userId-scoped; findUserAccountByStableUserId only returns the account whose email hashes to the given stable id.
  • NULL-plan invariant: intentionally excepted for inbound email resources (documented in entitlements.md) because inbound volume is attacker-controlled.
  • Fail-closed verified-email gate (Require verified account email for MCP and email features #637) preserved and strengthened: same reason/phase, now with bounded rejection rows and no quota consumption.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added stored-email search with query, inbox, direction, and status filtering.
    • Introduced an email usage view covering sends, receives, stored-message counts, and message-byte limits.
    • Expanded MCP email capabilities to support message search and usage retrieval.
  • Bug Fixes
    • Improved inbound-email quota enforcement for plan-less accounts, including per-day receive limits, stored-message caps, and message-size gating.
    • Inbound processing now records received-email usage with clearer success/error tracking.
  • Documentation
    • Updated architecture and usage-metering docs for inbound-email quotas, metering, and the new search/usage capabilities.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds inbound-email quota enforcement with NULL-plan fallback limits, records email_received usage, exposes email search and usage MCP capabilities, wires inbound-email data into admin usage views, and updates related documentation.

Changes

Inbound email entitlement, metering, and search

Layer / File(s) Summary
Plan limits and fallback resolution
packages/worker/src/entitlements/plans.ts, packages/worker/src/entitlements/entitlements.node.test.ts
Extends plan limits, entitlement resources, fallback limits, and limit-resolution helpers for inbound-email resources, with tests for plan-less fallback behavior.
Entitlement service updates
packages/worker/src/entitlements/service.ts
Adds stable-user lookup, updates email resource usage counting, and extends daily entitlement consumption with fallback limits.
Usage event type and admin metering model
packages/worker/src/usage/record-usage.ts, packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts, packages/worker/src/app/admin-usage-data.ts, packages/worker/src/app/loader-data.ts
Adds the inbound email usage event type and extends admin usage metric/resource schemas to recognize inbound-email counters.
Inbound handler enforcement and usage recording
packages/worker/src/email/inbound.ts, packages/worker/src/email/inbound.workers.test.ts, packages/worker/src/email/test-fixtures.ts, packages/worker/src/email/inbound-entitlements.workers.test.ts
Gates inbound email before parsing, records bounded rejection events and usage outcomes, and updates the handler tests and shared fixture helper.
Email search repository
packages/worker/src/email/repo.ts, packages/worker/src/email/repo-search.workers.test.ts
Adds escaped case-insensitive search over stored email messages and tests the filtering and ordering behavior.
MCP email search and usage capabilities
packages/worker/src/mcp/capabilities/email/*
Adds the email_message_search and email_usage_get capabilities, wires them into the email domain, and expands their validation and worker tests.
Admin usage dashboard wiring
packages/worker/client/routes/admin-usage.tsx, packages/worker/src/app/admin-usage-data.node.test.ts
Adds inbound-email metrics and resources to admin usage data, loader unions, dashboard labels, and node tests.
Architecture and usage documentation
docs/contributing/architecture/*, docs/use/email-primitives.md
Updates entitlements, storage, and email-primitive documentation for inbound-email fallback limits, contextless lookup, quotas, 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
Loading
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
Loading

Possibly related PRs

  • kentcdodds/kody#284: This PR builds on the inbound email primitives and repo work by extending the same email storage and handler flow.
  • kentcdodds/kody#619: Both PRs extend the entitlement/quota framework with new email resources and plan-less fallback behavior.
  • kentcdodds/kody#627: Both PRs extend the admin usage dashboard data and UI with new email usage metrics and entitlement resources.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: email inbox search, inbound quotas, receive metering, and the new email_usage_get capability.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/email-inbox-search-quotas-8ab0

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kentcdodds
kentcdodds marked this pull request as ready for review July 6, 2026 03:29
@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-636.kentcdodds.workers.dev

Worker: kody-pr-636
D1: kody-pr-636-db
KV: kody-pr-636-oauth-kv

Mocks:

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/worker/src/app/admin-usage-data.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Reuse canonical enum value constants instead of re-declaring literals.

direction and processing_status re-declare the value lists (['inbound','outbound'], ['stored','sent','failed']) instead of reusing emailDirectionValues/emailProcessingStatusValues from email/types.ts, which shared.ts already 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 | 🔵 Trivial

Substring search forces a full scan per user.

The leading % wildcard (needed for substring matching) combined with LOWER(...) wrapping the columns prevents any index from being used on subject/from_address/envelope_from, so this always does a linear scan over the user's rows (bounded by the user_id filter, 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

📥 Commits

Reviewing files that changed from the base of the PR and between f73e1ee and 6d919c5.

📒 Files selected for processing (23)
  • docs/contributing/architecture/entitlements.md
  • docs/contributing/architecture/usage-metering.md
  • docs/use/email-primitives.md
  • packages/worker/client/routes/admin-usage.tsx
  • packages/worker/src/app/admin-usage-data.node.test.ts
  • packages/worker/src/app/admin-usage-data.ts
  • packages/worker/src/app/loader-data.ts
  • packages/worker/src/email/inbound-entitlements.workers.test.ts
  • packages/worker/src/email/inbound.ts
  • packages/worker/src/email/inbound.workers.test.ts
  • packages/worker/src/email/repo-search.workers.test.ts
  • packages/worker/src/email/repo.ts
  • packages/worker/src/email/test-fixtures.ts
  • packages/worker/src/entitlements/entitlements.node.test.ts
  • packages/worker/src/entitlements/plans.ts
  • packages/worker/src/entitlements/service.ts
  • packages/worker/src/mcp/capabilities/admin/admin-usage-overview.ts
  • packages/worker/src/mcp/capabilities/email/domain.ts
  • packages/worker/src/mcp/capabilities/email/email-message-search.node.test.ts
  • packages/worker/src/mcp/capabilities/email/email-message-search.ts
  • packages/worker/src/mcp/capabilities/email/email-usage-get.ts
  • packages/worker/src/mcp/capabilities/email/email-usage-get.workers.test.ts
  • packages/worker/src/usage/record-usage.ts

Comment thread packages/worker/src/email/inbound.ts Outdated
Comment on lines +97 to +112
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,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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 -S

Repository: 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.ts

Repository: 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 -S

Repository: 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.sql

Repository: 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.

Comment thread packages/worker/src/entitlements/service.ts
@cursor
cursor Bot force-pushed the cursor/email-inbox-search-quotas-8ab0 branch from 5cd767c to a3eaa7a Compare July 6, 2026 04:52
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants