Email primitives
September 15, 2026 · View on GitHub
Kody has a storage-first email surface for Cloudflare Email Service and Email
Routing. Every user gets an automatic inbox address at
{username}@<platform domain>, where the platform domain is the deployment's
user email domain: the USER_EMAIL_DOMAIN env var when set, otherwise inbox.
plus the hostname of APP_BASE_URL (for example you@inbox.kody.codes). Kody
receives routed mail for that address, stores parsed messages for later
automation, and can send notify-self mail and replies from the matching
platform-assigned sender address.
Addressing model
- Inbound mail to
{username}@<platform domain>routes to the user who owns that username. The default inbox is provisioned automatically at signup (or on the first inbound message), so there is nothing to create or configure. - Subaddressing (RFC 5233 plus addressing) is supported: mail to
{username}+{tag}@<platform domain>routes to{username}'s inbox (andsupport+{tag}@<apex>to the corresponding system inbox). The base local part — everything before the first+— is what routes, so a tag can never bypass the reserved or unknown-username checks. Plus-tags on the user inbox host (inbox.kody.codesand anygetAcceptedUserEmailDomainshost) are user-inbox aliases, not reserved system locals:you+kody@inbox…,you+support@inbox…, andyou+admin@inbox…deliver toyouthe same wayyou+patch@inbox…does.kody@inbox…with no plus (the username itself is the reserved local) still rejects. System inboxes stay on the apex only (kody@kody.codesand the othersystemEmailLocals). The full tagged address is preserved in the stored message'sto_addresses, so a package subscribing toemail.message.receivedcan dispatch on the tag (for example, only handle mail addressed to{username}+invoices@...). - Mail to unknown usernames is rejected, and the app's apex domain is never a
user inbox: user mail lives exclusively on the configured platform domain (the
inbox.subdomain by default), while the apex hosts only system mail — the transactional sender (kody@<apex>, used for verification, password-reset, billing, entitlement warnings, usage-campaign nudges (Kody tips, with an unsubscribe footer andList-Unsubscribeheaders), and platform-feedback resolve/dismiss mail) and the operator-owned system inboxes (kody,support,abuse,postmaster,security,admin, andpslat the apex route to Kody's system inbox). Mail fromkody@<apex>setsReply-To: support@<apex>unless the caller supplies a different Reply-To, so human replies land at support rather than at the transactional sender. All other apex mail is rejected. - Reserved local parts never route to a user inbox and can never be registered as usernames.
- User outbound mail always sends from
{username}@<platform domain>. The from address is platform-assigned: a verified sender identity for it is provisioned automatically alongside the default inbox. There is no self-service sender registration or verification step.
Capabilities
Use the MCP email domain:
emailInboxListlists inboxes and automatic platform addresses for the signed-in user.emailDestinationListlists email destinationsemailSendmay use. The account identity email is always included. Extra addresses must be verified beforeemailSendcan use them. Cap is 5 extras besides the account email. Mail comes from{username}@<platform domain>.emailDestinationAddstarts verification for an extra address, or resends the verification email if that address is already pending. Unused links from earlier sends stay valid until they expire or the address is verified.emailDestinationSetDefaultpicks the destination used whenemailSendomitsto.emailDestinationRemoveremoves an extra destination. The identity email stays on the list.emailSendsends from your platform address to your verified email destinations. Destinations expand the allowedtoset; Kody is not an open relay. Omittoto use the default destination (the account email until you pick another). Every explicit address must already be on that verified set; if anytois missing or unverified the whole send fails. Optionalattachments(up to 10 of{ filename, content_type, content_base64 }) are sent with the message and stored asexternalattachments readable later viaemailAttachmentGet. The same attachments go to every allowedtoon one MIME message. With attachments, the whole message (bodies plus decoded attachment bytes) must fit the plan'semail_message_bytesper-message cap. Manage destinations from/account/emailas well. Unverified extras never receive mail.emailReplyreplies to a stored inbound message. The recipient always comes from the stored message. Optionalattachments(up to 10 of{ filename, content_type, content_base64 }) are sent with the reply and stored asexternalattachments readable later viaemailAttachmentGet. With attachments, the whole message (bodies plus decoded attachment bytes) must fit the plan'semail_message_bytesper-message cap.emailAttachmentGetreturns stored attachment bytes by attachment id.emailMessageListlists stored inbound and outbound messages. Rows includeclassificationandclassification_reason; pass an optionalclassificationfilter (acceptedorquarantined) to narrow the list.emailMessageSearchsearches stored messages by case-insensitive substring match against the subject, headerFrom, and envelope sender. It accepts the sameinbox_id/direction/processing_status/delivery_statusfilters and limit caps asemailMessageList.emailMessageGetreturns parsed bodies, headers, thread metadata, and attachment metadata.emailMessageDeletedeletes one stored inbound or outbound message owned by the signed-in user. Missing and foreign ids fail. Deleting frees astored_email_messagesslot so new inbound mail can be accepted again. The same delete is available on/account/email.emailMessageClassifyreclassifies a stored inbound message asacceptedorquarantined. Reclassification never retroactively dispatches package subscription events.emailSenderRuleListlists sender allow/block/quarantine rules.emailSenderRuleSetcreates or updates a sender rule by address or domain (kind/value/effect/ optionalnote).emailSenderRuleDeletedeletes one sender rule by id. Each user may store at most 200 sender rules.emailDeliveryEventListlists stored delivery history, including final Email Sending outcomes, SMTP responses, bounces, rejections, and complaints.usageGet(account domain) returns the signed-in user's entitlement usage and limits, including stored email message count, today's send and receive counts, the applicable caps, and the plan name.
Quotas
Inbound storage is quota-gated per user:
- A per-message persist cap (
email_message_bytes), a daily receive limit (email_receives_per_day), and a stored-message cap (stored_email_messages) apply at storage time. Mail over the daily or stored-message caps is rejected at the routing layer with a generic "over quota" response to the sender, and the detailed reason is recorded as arejecteddelivery event. Wire size above 25 MiB (Cloudflare Email Routing's inbound ceiling) is rejected before it consumes any daily receive quota. Mail to unverified accounts (which can never receive) is rejected without consuming any quota at all. Messages at or under the owner's persist cap — including amultipart/relatedbody with an embedded image — are stored as-is. Larger accepted mail is stored with the text kept and oversized parts omitted (emailAttachmentGetreturns no bytes). Transient storage failures (for example an R2 outage while saving raw MIME) do not keep the daily receive charge — the attempt is refunded so delivery retries are not blocked by quota. - Plan users get their plan's limits. New accounts start on the
freeplan. The operator-onlymaxplan uses finite email caps (10,000 sends/day, 20,000 receives/day, 100,000 stored messages, 768 KiB per message); it is not a public or paid tier. - Free email caps are 10 sends/day, 10 receives/day, 100 stored messages, and 256 KiB per message. Paid email caps are Standard: 200 sends/day, 1,000 receives/day, 10,000 stored messages; Pro: 500 sends/day, 2,000 receives/day, 25,000 stored messages. Both paid plans allow up to 768 KiB per message.
- Quota, size, and unverified-account rejections store at most five detailed
rejecteddelivery events per inbox per UTC day; further rejections increment a single daily aggregate event (with a total count and the last reason) so rejected floods cannot grow storage. Parse-failure rejections keep one event per attempt — they are already bounded by the daily receive quota and the detail helps debug a misbehaving sender. - Outbound sending stays limited by
email_sends_per_dayfor plan users. - Check where you stand with
usageGet. Whenstored_email_messagesis at the cap, inbound mail is rejected at routing andemail.message.receivedsubscriptions do not fire. Delete messages you no longer need withemailMessageDeleteor from/account/emailto free slots. Mailbox retention does not keep Free-tier inboxes under the stored-message cap.
Safety model
- Every email capability requires a verified account email. Until the
account email is verified (via the link sent at signup, or a resend from the
/accountpage), email capabilities are rejected, inbound mail routed to the account's platform address is rejected before storage, and MCP access as a whole is disabled. - Any email routed to a verified user's platform address is stored, subject to the quotas above.
- Unknown usernames and reserved local parts outside the configured system address subset are rejected before storage. Configured system addresses are stored in the operator-owned system inbox and are visible only to admins.
- Display names are not trusted. Kody stores envelope sender, parsed
From, and authentication headers separately. - Outbound sending requires a verified account email, sends only from the
platform-assigned address, and
emailSendonly delivers to the signed-in user's verified email destinations (the account identity email plus extra addresses they added and verified). Kody is not an open relay: destinations only expand the allowedtoset.emailReplyis the only way for a user account to address external recipients, and only recipients taken from stored inbound mail. Admins have a separate operator channel (adminSystemEmailSend) that speaks for the platform rather than for any user account: it sends from a reserved system sender, uses no user mailbox or plan entitlement, is audit-logged, and is capped per sender per UTC day. - Outbound sends consume a per-day entitlement. The
maxplan allows 10,000 send attempts per UTC day. - A successful send request has
processing_status: "sent". Cloudflare delivery events independently populatedelivery_statuswithdelivered,deferred,bounced,failed,rejected, orcomplained; useemailDeliveryEventListfor the event history and SMTP details. - Delivery events feed an automatic abuse pause: one spam complaint, or five or more bounced sends within a UTC day, pauses outbound sending for the account (receiving is unaffected). Every user sends from the same platform domain, so one account's complaints damage delivery for everyone. A paused send fails with a clear error; contact the operator to have the pause reviewed and cleared.
- System inbox mail is not gated by a user plan or account-verification state. It has fixed platform caps and retention: messages are pruned after 90 days and the stored system inbox is capped so arbitrary sender traffic cannot grow without bound. Operator sends from a system address are capped separately from receives, so a runaway sender can never block inbound system mail.
- Stored inbound mail is the source of truth. If a user wants email automation,
they can publish a package that subscribes to the stored inbound email topics
email.message.receivedoremail.message.quarantinedusing normal package subscriptions. This is package behavior, not a separate Kody-owned email handler or agent-loop primitive. - Subscription event payloads are metadata-first. Package handlers receive the
stored message id and receipt metadata, then use
emailMessageGetoremailAttachmentGet(orimport { email } from 'kody:runtime') when they need bodies or attachment bytes. - Subscription handlers run with the normal package runtime context: signed-in
package user, package-owned storage via
packageStorage()(package:{encodeURIComponent(packageId)}), package/repo context, and the standard capability registry subject to the usual secret and capability approval rules. Foremail.message.receivedandemail.message.quarantined,import { email }fromkody:runtimeis available as a convenience helper for message lookup, attachment lookup, and replies. - Attachments are metadata-first by default. Accepted inbound raw MIME is stored
in R2 at or under the owner's plan
email_message_bytespersist cap (maxRawMimeBytes/maxKeptInboundRawBytes, 256 KiB free and 768 KiB paid/max). The reader accepts wire size up to 25 MiB and reduces anything above the persist cap: text and HTML stay, oversized parts are recorded asunavailableattachments. On-demand attachment lookup reconstructs bytes from the stored raw MIME when the part was kept; omitted parts return no bytes. - Cloudflare Email Routing already rejects mail that fails both SPF and DKIM and honors sender DMARC policy before Kody sees the message. Kody's own spam controls (below) run on mail that still reaches storage.
Spam controls
Inbound mail is classified at receive time. Each stored message carries
classification (accepted or quarantined) and an optional human-readable
classification_reason. Decision order:
- Per-user sender rules — exact address or domain match (domain rules also
match subdomains). Address rules beat domain rules. Effects:
blockrejects at SMTP before any receive quota is charged.quarantinestores the message as quarantined.allowstores the message as accepted and bypasses the auth-verdict quarantine step below.
- Authentication-Results verdict — when no sender rule decides the outcome,
Kody parses the stored SPF/DKIM/DMARC results. DMARC
fail, or SPFfail/softfailwithout a DKIMpass, quarantines the message. A missing Authentication-Results header fails open toaccepted.
Each user may store at most 200 sender rules. Manage them with
emailSenderRuleList, emailSenderRuleSet (kind / value / effect / note), and
emailSenderRuleDelete. Reclassify a stored inbound message with
emailMessageClassify, or filter emailMessageList by classification.
On /account/email, quarantined messages show a Quarantined badge (with the
reason as tooltip/secondary text), the list can filter to Quarantined only, and
inbound messages offer Mark as spam / Not spam actions that call the same
reclassification path.
Subscription dispatch uses the receive-time classification exactly once:
accepted mail fires email.message.received; quarantined mail fires
email.message.quarantined instead. Later reclassification never retroactively
dispatches either topic.
email.message.received package subscription
Accepted stored inbound email dispatches the package subscription topic
email.message.received after the message and attachment metadata are stored.
Quarantined mail uses email.message.quarantined instead (same payload shape,
different event name). Packages subscribe in package.json#kody.subscriptions:
{
"kody": {
"subscriptions": {
"email.message.received": {
"handler": "./src/on-email-message-received.ts",
"description": "Process stored inbound mail."
}
}
}
}
Handlers receive a metadata-first payload:
type EmailMessageReceivedEvent = {
event: 'email.message.received'
message: {
id: string
inbox_id: string | null
from_address: string | null
envelope_from: string | null
to_addresses: Array<string>
cc_addresses: Array<string>
reply_to_addresses: Array<string>
subject: string | null
message_id_header: string | null
in_reply_to_header: string | null
references: Array<string>
processing_status: 'stored' | 'sent' | 'failed'
received_at: string | null
created_at: string
}
attachments: Array<{
id: string
filename: string | null
content_type: string | null
content_id: string | null
disposition: string | null
size: number
storage_kind: string
storage_key: string | null
created_at: string
}>
}
The event does not include parsed bodies or attachment bytes. Fetch those only
when the handler needs them with emailMessageGet, emailAttachmentGet, or the
package runtime email helper. Use packageSubscriptionsList with
topic: "email.message.received" or topic: "email.message.quarantined" to
discover which saved packages subscribe for the signed-in user.
email.message.quarantined package subscription
Quarantined stored inbound email dispatches email.message.quarantined instead
of email.message.received. The payload shape matches
EmailMessageReceivedEvent with event: 'email.message.quarantined'. Packages
that should react to spam or suspect mail subscribe to this topic; packages that
only want trusted inbound mail stay on email.message.received.
email.message.delivery.updated package subscription
Cloudflare Email Sending lifecycle events dispatch
email.message.delivery.updated after Kody correlates the provider message id,
stores the event idempotently, and updates the outbound message's latest
delivery status. Email Routing events are not part of this topic.
The metadata-first payload contains the owned Kody message and the provider delivery event:
type EmailMessageDeliveryUpdatedEvent = {
event: 'email.message.delivery.updated'
message: {
id: string
inbox_id: string | null
thread_id: string | null
from_address: string | null
to_addresses: Array<string>
subject: string | null
processing_status: 'stored' | 'sent' | 'failed'
provider_message_id: string | null
delivery_status:
| 'delivered'
| 'deferred'
| 'bounced'
| 'failed'
| 'rejected'
| 'complained'
| null
delivery_status_at: string | null
sent_at: string | null
created_at: string
}
delivery: {
event_id: string
status: NonNullable<
EmailMessageDeliveryUpdatedEvent['message']['delivery_status']
>
terminal: boolean
sender: string
recipient: string
delivery: Record<string, unknown>
bounce: Record<string, unknown> | null
failure: Record<string, unknown> | null
rejection: Record<string, unknown> | null
complaint: Record<string, unknown> | null
occurred_at: string
}
}
deferred means Cloudflare still has delivery retries pending; handlers should
not independently resend the message. Cloudflare automatically suppresses hard
bounces and spam complaints. Out-of-order events remain in delivery history but
do not dispatch after a newer delivery state has already been stored.
email.system-message.received package subscription (admins)
Mail stored in the operator-owned system inbox (kody, support, abuse,
postmaster, security, admin, and psl at the apex) dispatches the
separate package subscription topic email.system-message.received when the
message is accepted. It fans out to packages saved by users who hold the admin
role at dispatch time — a non-admin saving the same subscription never receives
system mail, and a revoked admin stops receiving immediately.
Quarantined system-inbox mail is stored for operators but never dispatches
email.system-message.received (or any other admin package subscription).
Operators manage system sender rules with adminSystemEmailSenderRuleList,
adminSystemEmailSenderRuleSet, and adminSystemEmailSenderRuleDelete (same
address/domain matching and effects as user sender rules, scoped to the
system:email owner).
The payload is the same metadata-first envelope as email.message.received
(with event: 'email.system-message.received'), plus one extra field:
type SystemEmailMessageReceivedEvent = Omit<
EmailMessageReceivedEvent,
'event'
> & {
event: 'email.system-message.received'
/** Link to the stored message in the admin interface. */
admin_url: string
}
Handlers run as the admin package owner, not the system owner, so the
user-scoped email capabilities and the kody:runtime email helper cannot read
the system message. Use the metadata for routing and notifications (for example
a Discord report), and follow admin_url (or the admin adminSystemEmailGet
capability) for full contents.
email.system-message.sent package subscription (admins)
A successful send from a reserved system sender (adminSystemEmailSend /
sendSystemEmail) fans email.system-message.sent to packages saved by users
who hold the admin role at dispatch time. Raw capability calls and utility
wrappers both go through that send path, so archive packages see every operator
send.
Outbound system mail is not stored on the dedicated inbound system_email_*
graph, so this topic carries the sent copy (recipients, subject, text, and HTML)
rather than a metadata-only pointer. Production fan-out is admin-only; synthetic
dispatch still runs a named handler for smoke tests.
type SystemEmailSentEvent = {
event: 'email.system-message.sent'
from: string
to: Array<string>
subject: string
text: string | null
html: string | null
reply_to: string | null
provider_message_id: string | null
sent_at: string
}
Local inbound testing
Run the worker locally with APP_BASE_URL set, sign up a user, then post raw
MIME to Wrangler's email test endpoint addressed to
{username}@inbox.<APP_BASE_URL hostname> (or {username}@<USER_EMAIL_DOMAIN>
when the override is set). The local worker defaults to port 3742 unless you
set PORT:
curl --request POST \
'http://localhost:3742/cdn-cgi/handler/email?from=sender@example.com&to=username@inbox.example.com' \
--data-raw 'From: Sender <sender@example.com>
To: Username <username@inbox.example.com>
Subject: Hello
Message-ID: <hello@example.com>
Hello from local email routing.'
Then inspect the message with emailMessageList, emailMessageSearch, and
emailMessageGet.