-
Notifications
You must be signed in to change notification settings - Fork 68
Add storage-only email primitives #284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 818ac3c
add email worker bindings and schema
cursoragent 265fa91
add email storage service
cursoragent 1f43143
add email MCP capabilities
cursoragent 1ed8f31
test email policy and parsing
cursoragent e946398
gate package email runtime surface
cursoragent e21df4c
document email primitives rollout
cursoragent c9fb644
test email storage flows
cursoragent 1668bd3
cover email helpers and domain
cursoragent 39721b5
align email code with schema constraints
cursoragent 16c6a4b
fix email policy idempotent upsert
cursoragent 5d45046
address email PR review feedback
cursoragent 6d3d902
address follow-up email review feedback
cursoragent 6dd9747
resolve final email review comments
cursoragent ed0a7e1
resolve final bugbot email findings
cursoragent 58ee424
address latest email review findings
cursoragent 139a2ab
support kody reply token parsing
cursoragent 8fdfa18
fix email reply token and provider ids
cursoragent 3c2f1d2
tighten email repository helpers
cursoragent ba4e2ae
harden email capability validation
cursoragent 058ee3b
dedupe email reply token handling
cursoragent 2266fe0
clean email helper surface
cursoragent c68d426
honor email inbox mode in policy
cursoragent 9203d10
remove unused email exports
cursoragent ddeae86
reduce email threading ambiguity
cursoragent a24a21c
clean email capability schemas
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')), | ||
| 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); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.