Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
638e709
add email parsing dependency
cursoragent Apr 30, 2026
818ac3c
add email worker bindings and schema
cursoragent Apr 30, 2026
265fa91
add email storage service
cursoragent Apr 30, 2026
1f43143
add email MCP capabilities
cursoragent Apr 30, 2026
1ed8f31
test email policy and parsing
cursoragent Apr 30, 2026
e946398
gate package email runtime surface
cursoragent Apr 30, 2026
e21df4c
document email primitives rollout
cursoragent Apr 30, 2026
c9fb644
test email storage flows
cursoragent Apr 30, 2026
1668bd3
cover email helpers and domain
cursoragent Apr 30, 2026
39721b5
align email code with schema constraints
cursoragent Apr 30, 2026
16c6a4b
fix email policy idempotent upsert
cursoragent Apr 30, 2026
5d45046
address email PR review feedback
cursoragent Apr 30, 2026
6d3d902
address follow-up email review feedback
cursoragent Apr 30, 2026
6dd9747
resolve final email review comments
cursoragent Apr 30, 2026
ed0a7e1
resolve final bugbot email findings
cursoragent Apr 30, 2026
58ee424
address latest email review findings
cursoragent Apr 30, 2026
139a2ab
support kody reply token parsing
cursoragent Apr 30, 2026
8fdfa18
fix email reply token and provider ids
cursoragent Apr 30, 2026
3c2f1d2
tighten email repository helpers
cursoragent Apr 30, 2026
ba4e2ae
harden email capability validation
cursoragent Apr 30, 2026
058ee3b
dedupe email reply token handling
cursoragent Apr 30, 2026
2266fe0
clean email helper surface
cursoragent Apr 30, 2026
c68d426
honor email inbox mode in policy
cursoragent Apr 30, 2026
9203d10
remove unused email exports
cursoragent Apr 30, 2026
ddeae86
reduce email threading ambiguity
cursoragent Apr 30, 2026
a24a21c
clean email capability schemas
cursoragent Apr 30, 2026
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
15 changes: 15 additions & 0 deletions docs/contributing/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ This project uses the following resources:
- KV namespace for OAuth/session storage
- `binding`: `OAUTH_KV`
- `title`: `<app-name>-oauth`
- Cloudflare Email Sending / Email Service Worker binding
- `binding`: `EMAIL`
- `wrangler` key: `send_email`
- Production domains still need Cloudflare-side sender/domain verification
before sends succeed.
- Cloudflare Email Routing for inbound mail
- Configure MX records and selected route aliases in the Cloudflare
dashboard.
- Route only test/storage aliases to the Worker until quarantine behavior is
verified.
- Vectorize indexes for MCP capability search (`CAPABILITY_VECTOR_INDEX`)
- Production: `kody-capabilities-prod`
- Preview: `kody-capabilities-preview`
Expand Down Expand Up @@ -67,6 +77,9 @@ automatically:
- `APP_COMMIT_SHA` (optional; set automatically by deploy workflows for
version-aware `/health` checks)
- `CLOUDFLARE_EMAIL_FROM` (optional; sender address for outbound email)
Existing app email uses this as a default sender. First-class MCP
`email_send` additionally requires a verified sender identity created through
the email capabilities.
- `AI_GATEWAY_ID` (required when `AI_MODE=remote`; deploy workflows sync a
gateway ID from GitHub Actions secrets so remote inference goes through
Cloudflare AI Gateway)
Expand Down Expand Up @@ -114,6 +127,8 @@ Configure these GitHub Actions secrets and variables for workflows:
- `AI_GATEWAY_ID_PREVIEW` (required for preview deploys that use remote AI
inference)
- `CLOUDFLARE_EMAIL_FROM` (optional, required to send app email)
First-class Kody email also requires the `EMAIL` send binding plus
Cloudflare Email Service sender/domain verification.
- `SENTRY_DSN` (optional; create a JavaScript/Cloudflare project in Sentry and
paste the DSN; syncs to the Worker as a secret when set in GitHub Actions)
- `CAPABILITY_REINDEX_SECRET` (optional; triggers post-deploy Vectorize reindex
Expand Down
51 changes: 51 additions & 0 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Email primitives

Kody has a storage-only email surface for Cloudflare Email Service and Email
Routing. It can send from verified identities, receive routed mail, store parsed
messages, and quarantine unknown senders.

## Capabilities

Use the MCP `email` domain:

- `email_inbox_create` creates an inbox and routable alias.
- `email_inbox_list` lists inboxes and aliases for the signed-in user.
- `email_sender_approve` verifies an outbound sender identity or allowlists an
inbound sender/domain for an inbox.
- `email_sender_revoke` disables an allow rule.
- `email_policy_get` inspects effective sender policy.
- `email_send` sends outbound mail from a verified sender identity.
- `email_reply` replies to a stored inbound message.
- `email_message_list` lists stored sent, accepted, quarantined, or failed
messages.
- `email_message_get` returns parsed bodies, headers, thread metadata, and
attachment metadata.

## Safety model

- Unknown inbound senders are quarantined.
- Display names are not trusted. Kody stores envelope sender, parsed `From`, and
authentication headers separately.
- Outbound sending requires a verified sender identity.
- Inbound package handlers are intentionally disabled in this first slice. Mail
is stored and audited only.
- Attachments are metadata-only for now; raw MIME for small messages is stored so
the first pass can be tested locally.

## Local inbound testing

Run the worker locally, create an inbox alias, then post raw MIME to Wrangler's
email test endpoint:

```sh
curl --request POST \
'http://localhost:8787/cdn-cgi/handler/email?from=sender@example.com&to=alias@example.com' \
--data-raw 'From: Sender <sender@example.com>
To: Alias <alias@example.com>
Subject: Hello
Message-ID: <hello@example.com>

Hello from local email routing.'
```

Then inspect the message with `email_message_list` and `email_message_get`.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/mock-servers/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,8 @@ async function handleEmailSend(
return envelope(
{
delivered: Array.isArray(payload.to) ? payload.to : [payload.to],
message_id: messageId,
messageId,
permanent_bounces: [],
queued: [],
},
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/outbound-email.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
array,
createSchema,
fail,
object,
optional,
string,
Expand All @@ -10,12 +12,38 @@ import { minLength } from 'remix/data-schema/checks'

const nonEmptyStringSchema = string().pipe(minLength(1))

const optionalHeadersSchema = createSchema<
unknown,
Record<string, string> | undefined
>((value, context) => {
if (value === undefined) return { value: undefined }
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return fail('Expected headers object', context.path)
}
const headers: Record<string, string> = {}
for (const [key, headerValue] of Object.entries(value)) {
if (typeof headerValue !== 'string') {
return fail(`Expected string header value for "${key}"`, context.path)
}
headers[key] = headerValue
}
return { value: headers }
})

const outboundEmailSchema = object({
from: nonEmptyStringSchema,
to: union([nonEmptyStringSchema, array(nonEmptyStringSchema)]),
subject: nonEmptyStringSchema,
html: nonEmptyStringSchema,
text: optional(nonEmptyStringSchema),
replyTo: optional(nonEmptyStringSchema),
headers: optionalHeadersSchema,
})

export type OutboundEmail = InferOutput<typeof outboundEmailSchema>
Expand Down
200 changes: 200 additions & 0 deletions packages/worker/migrations/0030-email-primitives.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
CREATE TABLE IF NOT EXISTS email_sender_identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
package_id TEXT,
email TEXT NOT NULL,
domain TEXT,
display_name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('pending', 'verified', 'disabled')),
Comment thread
cursor[bot] marked this conversation as resolved.
verified_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_sender_identities_user_email
ON email_sender_identities(user_id, email);

CREATE INDEX IF NOT EXISTS idx_email_sender_identities_user_domain
ON email_sender_identities(user_id, domain);

CREATE TABLE IF NOT EXISTS email_inboxes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
package_id TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL CHECK (mode IN ('quarantine', 'accept')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_inboxes_user_name
ON email_inboxes(user_id, name);

CREATE INDEX IF NOT EXISTS idx_email_inboxes_user_created_at
ON email_inboxes(user_id, created_at);

CREATE TABLE IF NOT EXISTS email_inbox_addresses (
id TEXT PRIMARY KEY,
inbox_id TEXT NOT NULL,
user_id TEXT NOT NULL,
address TEXT NOT NULL,
local_part TEXT NOT NULL,
domain TEXT NOT NULL,
reply_token_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (inbox_id) REFERENCES email_inboxes(id) ON DELETE CASCADE
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_inbox_addresses_address
ON email_inbox_addresses(address);

CREATE INDEX IF NOT EXISTS idx_email_inbox_addresses_inbox_id
ON email_inbox_addresses(inbox_id);

CREATE INDEX IF NOT EXISTS idx_email_inbox_addresses_reply_token
ON email_inbox_addresses(reply_token_hash);

CREATE TABLE IF NOT EXISTS email_threads (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
inbox_id TEXT,
subject_normalized TEXT NOT NULL DEFAULT '',
root_message_id_header TEXT,
last_message_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_email_threads_user_last_message_at
ON email_threads(user_id, last_message_at);

CREATE INDEX IF NOT EXISTS idx_email_threads_root_message_id
ON email_threads(root_message_id_header);

CREATE TABLE IF NOT EXISTS email_messages (
id TEXT PRIMARY KEY,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
user_id TEXT NOT NULL,
inbox_id TEXT,
thread_id TEXT,
sender_identity_id TEXT,
from_address TEXT NOT NULL,
envelope_from TEXT,
to_addresses_json TEXT NOT NULL DEFAULT '[]',
cc_addresses_json TEXT NOT NULL DEFAULT '[]',
bcc_addresses_json TEXT NOT NULL DEFAULT '[]',
reply_to_addresses_json TEXT NOT NULL DEFAULT '[]',
subject TEXT NOT NULL DEFAULT '',
message_id_header TEXT,
in_reply_to_header TEXT,
references_json TEXT NOT NULL DEFAULT '[]',
headers_json TEXT NOT NULL DEFAULT '{}',
auth_results TEXT,
text_body TEXT,
html_body TEXT,
raw_mime TEXT,
raw_size INTEGER NOT NULL DEFAULT 0,
policy_decision TEXT NOT NULL CHECK (policy_decision IN ('accepted', 'quarantined', 'rejected')),
processing_status TEXT NOT NULL CHECK (processing_status IN ('stored', 'sent', 'failed', 'rejected')),
provider_message_id TEXT,
error TEXT,
received_at TEXT,
sent_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (inbox_id) REFERENCES email_inboxes(id) ON DELETE SET NULL,
FOREIGN KEY (thread_id) REFERENCES email_threads(id) ON DELETE SET NULL,
FOREIGN KEY (sender_identity_id) REFERENCES email_sender_identities(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_email_messages_user_created_at
ON email_messages(user_id, created_at);

CREATE INDEX IF NOT EXISTS idx_email_messages_inbox_created_at
ON email_messages(inbox_id, created_at);

CREATE INDEX IF NOT EXISTS idx_email_messages_thread_created_at
ON email_messages(thread_id, created_at);

CREATE INDEX IF NOT EXISTS idx_email_messages_policy_created_at
ON email_messages(policy_decision, created_at);

CREATE INDEX IF NOT EXISTS idx_email_messages_message_id_header
ON email_messages(message_id_header);

CREATE TABLE IF NOT EXISTS email_attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
filename TEXT,
content_type TEXT NOT NULL,
content_id TEXT,
disposition TEXT,
size INTEGER NOT NULL DEFAULT 0,
storage_kind TEXT NOT NULL CHECK (storage_kind IN ('raw-mime', 'external', 'unavailable')),
storage_key TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES email_messages(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_email_attachments_message_id
ON email_attachments(message_id);

CREATE TABLE IF NOT EXISTS email_sender_policies (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
inbox_id TEXT,
package_id TEXT,
kind TEXT NOT NULL CHECK (kind IN ('sender', 'domain', 'reply_token')),
value TEXT NOT NULL,
effect TEXT NOT NULL CHECK (effect IN ('allow', 'quarantine', 'reject')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (inbox_id) REFERENCES email_inboxes(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_email_sender_policies_inbox_kind_value
ON email_sender_policies(inbox_id, kind, value);

CREATE INDEX IF NOT EXISTS idx_email_sender_policies_user_kind_value
ON email_sender_policies(user_id, kind, value);

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_sender_policies_user_kind_value_inbox_null_package_null
ON email_sender_policies(user_id, kind, value)
WHERE inbox_id IS NULL AND package_id IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_sender_policies_user_kind_value_inbox_package_null
ON email_sender_policies(user_id, kind, value, inbox_id)
WHERE inbox_id IS NOT NULL AND package_id IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_sender_policies_user_kind_value_package_inbox_null
ON email_sender_policies(user_id, kind, value, package_id)
WHERE inbox_id IS NULL AND package_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_email_sender_policies_user_kind_value_inbox_package
ON email_sender_policies(user_id, kind, value, inbox_id, package_id)
WHERE inbox_id IS NOT NULL AND package_id IS NOT NULL;

CREATE TABLE IF NOT EXISTS email_delivery_events (
id TEXT PRIMARY KEY,
message_id TEXT,
user_id TEXT,
inbox_id TEXT,
event_type TEXT NOT NULL CHECK (event_type IN ('receive_started', 'received', 'quarantined', 'rejected', 'send_requested', 'sent', 'failed', 'policy_matched')),
provider TEXT NOT NULL DEFAULT 'kody',
provider_message_id TEXT,
detail_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES email_messages(id) ON DELETE SET NULL,
FOREIGN KEY (inbox_id) REFERENCES email_inboxes(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_email_delivery_events_message_id
ON email_delivery_events(message_id);

CREATE INDEX IF NOT EXISTS idx_email_delivery_events_user_created_at
ON email_delivery_events(user_id, created_at);
1 change: 1 addition & 0 deletions packages/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"acorn": "^8.16.0",
"agents": "^0.11.1",
"croner": "^10.0.1",
"postal-mime": "^2.7.4",
"remix": "3.0.0-alpha.3",
"workers-ai-provider": "^3.1.11",
"zod": "^4.3.6"
Expand Down
5 changes: 3 additions & 2 deletions packages/worker/src/app/email/cloudflare-email.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ test('sendCloudflareEmail posts to the mock Cloudflare email API', async () => {
},
)

expect(sendResult).toEqual({
expect(sendResult).toMatchObject({
ok: true,
})
expect(sendResult.messageId).toMatch(/^email_/)

const response = await fetch(`${mock.origin}/__mocks/messages?token=${token}`)
expect(response.status).toBe(200)
Expand Down Expand Up @@ -156,7 +157,7 @@ test('sendCloudflareEmail defaults the API base URL when it is unset', async ()
},
)

expect(result).toEqual({
expect(result).toMatchObject({
ok: true,
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
Expand Down
Loading
Loading