Skip to content

Add attachment support to email_reply outbound sends - #701

Merged
kody-bot merged 7 commits into
mainfrom
cursor/email-reply-attachments-e399
Jul 10, 2026
Merged

kody-bot merged 7 commits into
mainfrom
cursor/email-reply-attachments-e399

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 10, 2026 •

Copy link
Copy Markdown
Owner

What

email_reply (and the email.reply runtime helper that delegates to it) now accepts an optional attachments array — up to 10 of { filename, content_type, content_base64 } — and sends the files with the reply.

How

  • sendOutboundEmail validates and decodes attachments up front (count cap, base64 validity, non-empty), passes raw bytes to the EMAIL binding and base64 to the Cloudflare REST fallback (both provider paths support attachments natively).
  • Attachment bytes are persisted per-file in EMAIL_BLOBS under a new key contract email-attachment:v1:{userId}/{messageId}/{attachmentId} with email_attachments rows using storage_kind: 'external', so email_attachment_get serves outbound attachments through a new external-storage read branch in getEmailAttachmentById. A failed blob put degrades that attachment to metadata-only (storage_kind: 'unavailable') instead of blocking the send.
  • Entitlements: with attachments present, the whole message (bodies + decoded attachment bytes) is gated by the plan's email_message_bytes per-message cap (fallback backstop for plan-less users); body-only sends keep their existing behavior. Storage-bytes estimation and email_send usage bytes now include attachment payloads.
  • Lifecycle: deleteEmailMessageById, hourly retention pruning, and account deletion all clean up external attachment blobs (account deletion's email blob enumeration now includes attachment storage keys).

Testing

  • email-reply.node.test.ts: attachments pass through the capability to sendOutboundEmail.
  • outbound.workers.test.ts: binding receives raw bytes and the stored attachment round-trips through getEmailAttachmentById; REST fallback receives base64 attachments; invalid base64 and oversize attachments are rejected before consuming daily send quota; message deletion removes the external blob.
  • retention.node.test.ts: prune deletes external attachment blobs alongside raw-MIME blobs.
  • npm run validate green locally (800 unit tests, Playwright E2E, MCP E2E).
  • Review follow-ups: REST payload keeps an explicit contentId key for the schema type; attachment persistence failures mark the stored message failed (instead of leaving it in stored limbo); attachment row inserts are batched (chunked at 50); a 5 MiB combined hard cap is enforced from base64 lengths before decoding; retention blob deletes are chunked below the R2 1000-key bulk limit.
System recap — extends existing primitives (medium risk)

Mode: recap · Base: main @ e5a88469 · Head: 231e249c

Classification: extends — the email send contract and the email blob storage key contract both gain attachment support; no new primitives.

Primitives touched

Primitive Group Impact
email assistant extends — email_reply input gains attachments; outbound pipeline stores and sends them
email-blobs-r2 storage extends — new email-attachment:v1:{userId}/{messageId}/{attachmentId} key contract
entitlements platform composes — outbound attachments gated by existing email_message_bytes / storage_bytes
usage-metering platform composes — email_send usage bytes include attachment payloads
retention platform extends — email prune also deletes external attachment blobs (chunked bulk deletes)
account-deletion platform extends — email blob enumeration includes attachment storage keys

System map

A reply with attachments flows from the email_reply capability through the outbound pipeline into R2 blob storage and out via the Cloudflare email provider; retention and account deletion clean the new blobs up.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	email["email<br/>Email"]:::extended
	emailBlobs["email-blobs-r2<br/>Email blob storage (R2)"]:::extended
	entitlements["entitlements<br/>Entitlements"]:::touched
	usage["usage-metering<br/>Usage metering"]:::touched
	retention["cron-retention<br/>Cron + retention"]:::extended
	accountDeletion["account-deletion<br/>Account deletion"]:::extended
	email -->|"email-attachment:v1 blob put + external rows"| emailBlobs
	email -->|"email_message_bytes gate on body+attachments"| entitlements
	email -->|"email_send bytes include attachments"| usage
	retention -->|"prune deletes attachment blobs"| emailBlobs
	accountDeletion -->|"enumerates attachment storage keys"| emailBlobs
	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

Before / after

email_reply input
  before: { message_id, text?, html? }
  after:  { message_id, text?, html?,
            attachments?: [{ filename, content_type, content_base64 }] (max 10, 5 MiB combined) }

email_attachments rows for outbound mail
  before: none
  after:  storage_kind 'external' + storage_key email-attachment:v1:{userId}/{messageId}/{attachmentId}
          (or 'unavailable' when the blob put failed)

Invariants

Per-user isolation holds: the new R2 key contract embeds userId, the external read path stays behind the userId-scoped email_attachments join, and account deletion / retention enumerate and delete the new blobs by stored keys.

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added optional email reply attachments (up to 10) and full outbound email attachment support.
    • Attachments are stored as retrievable external email blobs and included in delivery across primary and fallback sending paths.
  • Bug Fixes
    • Improved retention and account-deletion cleanup to remove both raw MIME blobs and external attachment blobs, reducing orphaned data.
  • Documentation
    • Updated email primitives and reply capability docs to reflect external attachment storage and limits.
  • Tests
    • Expanded coverage for attachment forwarding, validation, size/format limits, and quota/usage behavior.

cursoragent and others added 2 commits July 10, 2026 05:25
Attachments (filename, content_type, content_base64; up to 10) ride the
reply through both provider paths: raw bytes via the EMAIL binding and
base64 via the REST fallback. Bytes are stored per-attachment in R2
(storage_kind 'external') so email_attachment_get serves outbound
attachments, with metadata-only degradation when the blob put fails.

With attachments the whole message is gated by the plan's
email_message_bytes cap; storage-bytes estimation and email_send usage
bytes include attachment payloads. Retention pruning, message deletion,
and account deletion now clean up external attachment blobs.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds optional outbound email attachments with validation, R2-backed external storage, provider delivery, retrieval, usage accounting, retention cleanup, account deletion support, and updated reply and architecture documentation.

Changes

Outbound email attachments

Layer / File(s) Summary
Attachment contracts and reply input
packages/shared/src/outbound-email.ts, packages/worker/src/mcp/capabilities/email/email-reply.ts, packages/worker/src/mcp/capabilities/email/email-reply.node.test.ts, docs/use/email-primitives.md, docs/contributing/architecture/primitives.yaml
Attachment schemas, reply validation, field mapping, tests, and storage-contract documentation support up to 10 optional attachments.
Attachment preparation and persistence
packages/worker/src/email/outbound.ts, packages/worker/src/email/repo.ts
Attachment content is validated and decoded, stored in R2 when possible, recorded in email_attachments, retrieved through attachment APIs, and removed with message deletion.
Provider delivery and usage accounting
packages/worker/src/app/email/cloudflare-email.ts, packages/worker/src/email/outbound.ts, packages/worker/src/email/outbound.workers.test.ts
Binding delivery receives bytes, REST fallback receives base64 content, and attachment bytes contribute to entitlement checks, usage totals, and delivery metadata.
Retention and account cleanup
packages/worker/src/app/retention.ts, packages/worker/src/app/retention.node.test.ts, packages/worker/src/app/account-deletion.ts
Retention and account deletion enumerate and delete external attachment blobs alongside raw MIME blobs, with expanded counters and coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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 and concisely describes the main change: adding attachment support to email_reply outbound sends.
✨ 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-reply-attachments-e399

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.

@kody-bot
kody-bot marked this pull request as ready for review July 10, 2026 05:28
@github-actions

github-actions Bot commented Jul 10, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-701.kody-a99.workers.dev

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

Mocks:

@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: 2

🧹 Nitpick comments (5)
packages/worker/src/app/account-deletion.ts (2)

377-379: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use getErrorMessage for consistency with sibling catch blocks.

This catch block uses error instanceof Error ? error.message : String(error) while every other handler in the same Promise.all uses getErrorMessage(error). Aligning the pattern improves consistency and ensures any extra error-normalization logic in getErrorMessage applies here too.

♻️ Proposed refactor
 	listUserEmailBlobKeys(input.env, input.userId).catch((error) => {
-		const message = error instanceof Error ? error.message : String(error)
+		const message = getErrorMessage(error)
 		input.warnings.push(`Failed to enumerate email blob keys: ${message}`)
 		return [] as Array<string>
 	}),
🤖 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/app/account-deletion.ts` around lines 377 - 379, Replace
the inline error conversion in the catch callback for listUserEmailBlobKeys with
getErrorMessage(error), matching the other Promise.all handlers and reusing the
existing normalization helper.

286-307: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider parallelizing the two independent D1 queries in listUserEmailBlobKeys.

The raw-MIME and attachment-key queries are independent and could run concurrently via Promise.all, halving the latency of this inventory step.

♻️ Proposed refactor
 async function listUserEmailBlobKeys(env: Env, userId: string) {
-	const rawMimeRows = await env.APP_DB.prepare(
+	const [rawMimeRows, attachmentRows] = await Promise.all([
+		env.APP_DB.prepare(
 		`SELECT raw_mime_key
 		FROM email_messages
 		WHERE user_id = ? AND raw_mime_key IS NOT NULL`,
 	)
 		.bind(userId)
-		.all<{ raw_mime_key: string }>()
-	// Externally stored attachments (outbound mail) have their own R2
-	// objects, separate from any raw-MIME blob.
-	const attachmentRows = await env.APP_DB.prepare(
+		.all<{ raw_mime_key: string }>(),
+		// Externally stored attachments (outbound mail) have their own R2
+		// objects, separate from any raw-MIME blob.
+		env.APP_DB.prepare(
 		`SELECT attachment.storage_key AS storage_key
 		FROM email_attachments attachment
 		JOIN email_messages message ON message.id = attachment.message_id
 		WHERE message.user_id = ? AND attachment.storage_key IS NOT NULL`,
 	)
 		.bind(userId)
-		.all<{ storage_key: string }>()
+		.all<{ storage_key: string }>(),
+	])
 	return uniqueStrings([
 		...(rawMimeRows.results ?? []).map((row) => row.raw_mime_key),
 		...(attachmentRows.results ?? []).map((row) => row.storage_key),
 	])
 }
🤖 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/app/account-deletion.ts` around lines 286 - 307,
Parallelize the independent raw-MIME and attachment-key queries in
listUserEmailBlobKeys by creating both D1 query promises before awaiting them,
then await them together with Promise.all and preserve the existing result
mapping and uniqueStrings behavior.
packages/worker/src/app/retention.ts (2)

813-817: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

deletedAttachmentBlobs calculation is indirect and fragile.

blobKeys.length - result.deletedRawMimeBlobs works only because each row contributes at most one raw_mime_key. If the data model ever allows multiple raw-MIME keys per row, this subtraction would produce incorrect attachment counts. Computing the attachment count explicitly from the map is more robust.

♻️ Proposed explicit calculation
 	try {
 		await input.blobs.delete(blobKeys)
 		result.deletedRawMimeBlobs = rowsWithBlob.filter(
 			(row) => row.raw_mime_key !== null,
 		).length
-		result.deletedAttachmentBlobs =
-			blobKeys.length - result.deletedRawMimeBlobs
+		result.deletedAttachmentBlobs = rowsWithBlob.reduce(
+			(count, row) =>
+				count + (attachmentKeysByMessageId.get(row.id)?.length ?? 0),
+			0,
+		)
 	} catch (error) {
🤖 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/app/retention.ts` around lines 813 - 817, In the
retention result calculation, replace the indirect `blobKeys.length -
result.deletedRawMimeBlobs` assignment with an explicit count of attachment
blobs derived from `blobKeys` or the associated map, excluding entries
representing `raw_mime_key`; update `result.deletedAttachmentBlobs` directly so
the count remains correct if a row can contain multiple raw-MIME keys.

779-779: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the local placeholder string packages/worker/src/app/retention.ts:779 The const placeholders in the attachment loop shadows the module-level placeholders(values) helper used later in the file. Rename it to something like chunkPlaceholders to avoid the overlap.

🤖 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/app/retention.ts` at line 779, The attachment loop’s
local placeholders string conflicts with the module-level placeholders(values)
helper. In the attachment loop, rename the local const placeholders variable to
chunkPlaceholders and update its usages in that loop.
packages/worker/src/email/outbound.ts (1)

339-349: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

contentId: undefined is redundant in the REST payload.

JSON.stringify omits properties with undefined values, so contentId: undefined has no effect on the serialized request body. It can be removed for clarity, or if the REST API actually expects contentId to be absent for attachments, a comment explaining the omission would suffice.

♻️ Proposed cleanup
 					? // The REST API expects base64 string content.
 						input.attachments.map((attachment) => ({
 							content: attachment.contentBase64,
 							filename: attachment.filename,
 							type: attachment.contentType,
 							disposition: 'attachment' as const,
-							contentId: undefined,
 						}))
 					: undefined,
🤖 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/outbound.ts` around lines 339 - 349, Remove the
redundant contentId: undefined property from the attachment objects created in
the outbound email payload mapping, preserving the existing fields and behavior.
🤖 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/outbound.ts`:
- Around line 202-252: storeOutboundAttachments can orphan successfully uploaded
R2 blobs when insertEmailAttachments fails. Track the storage keys for
attachments where the blob put succeeded, wrap the insertEmailAttachments call
in a try/catch, delete those keys from input.blobs on failure, then rethrow the
original database error.

In `@packages/worker/src/email/repo.ts`:
- Around line 903-939: Replace the separate D1 delete calls in the message
cleanup function with a single input.db.batch() containing the email_attachments
and email_messages DELETE statements, preserving their order and bindings so
both deletes commit or roll back together before the R2 deletion loop.

---

Nitpick comments:
In `@packages/worker/src/app/account-deletion.ts`:
- Around line 377-379: Replace the inline error conversion in the catch callback
for listUserEmailBlobKeys with getErrorMessage(error), matching the other
Promise.all handlers and reusing the existing normalization helper.
- Around line 286-307: Parallelize the independent raw-MIME and attachment-key
queries in listUserEmailBlobKeys by creating both D1 query promises before
awaiting them, then await them together with Promise.all and preserve the
existing result mapping and uniqueStrings behavior.

In `@packages/worker/src/app/retention.ts`:
- Around line 813-817: In the retention result calculation, replace the indirect
`blobKeys.length - result.deletedRawMimeBlobs` assignment with an explicit count
of attachment blobs derived from `blobKeys` or the associated map, excluding
entries representing `raw_mime_key`; update `result.deletedAttachmentBlobs`
directly so the count remains correct if a row can contain multiple raw-MIME
keys.
- Line 779: The attachment loop’s local placeholders string conflicts with the
module-level placeholders(values) helper. In the attachment loop, rename the
local const placeholders variable to chunkPlaceholders and update its usages in
that loop.

In `@packages/worker/src/email/outbound.ts`:
- Around line 339-349: Remove the redundant contentId: undefined property from
the attachment objects created in the outbound email payload mapping, preserving
the existing fields and behavior.
🪄 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: 342c73d0-b65c-458b-80a4-a0b7e15567f8

📥 Commits

Reviewing files that changed from the base of the PR and between e5a8846 and 21dbe11.

📒 Files selected for processing (12)
  • docs/contributing/architecture/primitives.yaml
  • docs/use/email-primitives.md
  • packages/shared/src/outbound-email.ts
  • packages/worker/src/app/account-deletion.ts
  • packages/worker/src/app/email/cloudflare-email.ts
  • packages/worker/src/app/retention.node.test.ts
  • packages/worker/src/app/retention.ts
  • packages/worker/src/email/outbound.ts
  • packages/worker/src/email/outbound.workers.test.ts
  • packages/worker/src/email/repo.ts
  • packages/worker/src/mcp/capabilities/email/email-reply.node.test.ts
  • packages/worker/src/mcp/capabilities/email/email-reply.ts

Comment thread packages/worker/src/email/outbound.ts
Comment thread packages/worker/src/email/repo.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/src/email/outbound.ts Outdated
Comment thread packages/worker/src/email/outbound.ts
cursoragent and others added 2 commits July 10, 2026 06:32
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
…h attachment inserts

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/src/email/outbound.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@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 using default effort and found 2 potential issues.

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 278b484. Configure here.

Comment thread packages/worker/src/app/retention.ts Outdated
Comment thread packages/worker/src/email/repo.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kody-bot
kody-bot merged commit 0c0cfbe into main Jul 10, 2026
5 checks passed
@kody-bot
kody-bot deleted the cursor/email-reply-attachments-e399 branch July 10, 2026 07:15
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