From 498697b95041262caa22a0696be06e076544add1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 15:19:25 +0000 Subject: [PATCH 1/2] Move user email addressing to the inbox. subdomain User inboxes and outbound senders now live at {username}@inbox. (e.g. kentcdodds@inbox.heykody.dev) instead of the zone apex, with an optional USER_EMAIL_DOMAIN env override. The subdomain replaces denylist-only separation with structural separation: the user-controlled address namespace can never collide with system transactional mail (kody@ stays apex-only and the apex is no longer a user inbox), and user outbound sender reputation is isolated from the system sender. The reserved-username denylist stays as signup/scope protection and inbound defense-in-depth. Everything funnels through getPlatformEmailDomain, so inbound routing, outbound from, inbox auto-provisioning, and sender-identity provisioning all follow from the one derivation change. --- docs/contributing/environment-variables.md | 9 ++++ docs/use/email-primitives.md | 34 ++++++------ packages/worker/.env.example | 6 +++ .../inbound-entitlements.workers.test.ts | 3 +- packages/worker/src/email/inbound.ts | 37 +++++++++---- .../worker/src/email/inbound.workers.test.ts | 17 +++++- packages/worker/src/email/outbound.ts | 1 + .../worker/src/email/outbound.workers.test.ts | 3 +- .../src/email/platform-address.node.test.ts | 46 ++++++++++++++++ packages/worker/src/email/platform-address.ts | 54 +++++++++++++++++-- .../src/email/system-email.workers.test.ts | 29 +++++++--- packages/worker/src/env-schema.ts | 1 + 12 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 packages/worker/src/email/platform-address.node.test.ts diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index c4d513198e..b89d157388 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -132,6 +132,15 @@ Optional Worker secrets/vars (see `packages/worker/src/env-schema.ts` and control-plane endpoints used by `packages/worker/src/repo/artifacts.ts` (`repos`, `tokens`, and `fork`), so local repo create/get/list/token/fork calls do not need the live Artifacts REST API. +- `USER_EMAIL_DOMAIN` — optional override for the user email domain (see + `packages/worker/src/email/platform-address.ts`). Defaults to + `inbox.` (for example `inbox.heykody.dev`): every user + inbox and user outbound sender lives at `{username}@`. User mail + deliberately lives on a subdomain so the user-controlled namespace and its + sender reputation stay separate from system transactional mail + (`kody@`). The deployment's Cloudflare zone needs Email Routing enabled + for this subdomain (Email > Email Routing > Settings > Add subdomain) with its + catch-all routed to the Worker. - `ARTIFACTS_NAMESPACE` — Cloudflare Artifacts namespace for repo REST calls. Defaults to `default` when unset (local dev and tests). Wrangler sets `production` and `preview` per environment in diff --git a/docs/use/email-primitives.md b/docs/use/email-primitives.md index ac0d6c26fe..9849a13471 100644 --- a/docs/use/email-primitives.md +++ b/docs/use/email-primitives.md @@ -2,24 +2,27 @@ Kody has a storage-first email surface for Cloudflare Email Service and Email Routing. Every user gets an automatic inbox address at -`{username}@` (the hostname of the deployment's `APP_BASE_URL`, -for example `you@heykody.dev`). 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. +`{username}@`, 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.heykody.dev`). 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}@` 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. -- Mail to unknown usernames is rejected. +- Mail to unknown usernames is rejected, and the app's apex domain is never a + user inbox: user mail lives exclusively on the dedicated subdomain, while the + apex hosts only system mail — the transactional sender (`kody@`, used + for verification and password-reset mail) and the operator-owned system + inboxes (`kody`, `support`, `abuse`, `postmaster`, `security`, and `admin` at + the apex route to Kody's system inbox, so replies to transactional mail land + there). All other apex mail is rejected. - Reserved local parts never route to a user inbox and can never be registered - as usernames. A configured subset is operator-owned system mail: `kody`, - `support`, `abuse`, `postmaster`, `security`, and `admin` at the platform - domain route to Kody's system inbox. Other reserved locals still reject. -- `kody@` is also the system transactional sender for - verification and password-reset mail. Inbound replies to that address are - operator-owned system mail, not a user's inbox. + as usernames. - User outbound mail always sends from `{username}@`. 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 @@ -174,14 +177,15 @@ the signed-in user. 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}@`. The local worker defaults to port `3742` -unless you set `PORT`: +`{username}@inbox.` (or `{username}@` +when the override is set). The local worker defaults to port `3742` unless you +set `PORT`: ```sh curl --request POST \ - 'http://localhost:3742/cdn-cgi/handler/email?from=sender@example.com&to=username@example.com' \ + 'http://localhost:3742/cdn-cgi/handler/email?from=sender@example.com&to=username@inbox.example.com' \ --data-raw 'From: Sender -To: Username +To: Username Subject: Hello Message-ID: diff --git a/packages/worker/.env.example b/packages/worker/.env.example index eeadb4cde1..975fba24e7 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -13,6 +13,12 @@ SECRET_STORE_KEY=LOCAL_SECRET_STORE_KEY_32_CHARS_MINIMUM_VALUE # from kody@ when set) APP_BASE_URL= +# Optional override for the user email domain. Defaults to +# inbox. (e.g. inbox.heykody.dev): every user inbox +# and user outbound sender lives at {username}@, keeping user +# mail separate from system transactional mail on the apex. +# USER_EMAIL_DOMAIN= + # Workers AI embedding gateway (optional; production/preview sync this as a Worker secret) # AI_GATEWAY_ID=your-cloudflare-ai-gateway-id # CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id diff --git a/packages/worker/src/email/inbound-entitlements.workers.test.ts b/packages/worker/src/email/inbound-entitlements.workers.test.ts index 68cc303f73..f2823e3635 100644 --- a/packages/worker/src/email/inbound-entitlements.workers.test.ts +++ b/packages/worker/src/email/inbound-entitlements.workers.test.ts @@ -16,7 +16,8 @@ import { createForwardableEmailMessage } from './test-fixtures.ts' import { ensureEmailTestSchema } from './test-schema.ts' const platformBaseUrl = 'https://kody.example.com' -const platformDomain = 'kody.example.com' +// User mail lives on the inbox. subdomain derived from APP_BASE_URL. +const platformDomain = 'inbox.kody.example.com' function createInboundEnv() { return { ...env, APP_BASE_URL: platformBaseUrl } diff --git a/packages/worker/src/email/inbound.ts b/packages/worker/src/email/inbound.ts index d423377273..35443c192a 100644 --- a/packages/worker/src/email/inbound.ts +++ b/packages/worker/src/email/inbound.ts @@ -17,7 +17,10 @@ import { maxInlineRawMimeBytes, parseForwardableEmailMessage, } from './parser.ts' -import { getPlatformEmailDomain } from './platform-address.ts' +import { + getPlatformEmailDomain, + getSystemEmailDomain, +} from './platform-address.ts' import { createEmailThread, findEmailThreadForInboundMessage, @@ -146,7 +149,11 @@ export async function handleInboundEmail( message: ForwardableEmailMessage, env: Pick< Env, - 'APP_DB' | 'BUNDLE_ARTIFACTS_KV' | 'APP_BASE_URL' | 'USAGE_EVENTS' + | 'APP_DB' + | 'BUNDLE_ARTIFACTS_KV' + | 'APP_BASE_URL' + | 'USER_EMAIL_DOMAIN' + | 'USAGE_EVENTS' >, _ctx?: ExecutionContext, ) { @@ -157,7 +164,8 @@ export async function handleInboundEmail( } const platformDomain = getPlatformEmailDomain(env) - if (!platformDomain) { + const systemDomain = getSystemEmailDomain(env) + if (!platformDomain && !systemDomain) { message.setReject('Email routing is not configured.') return } @@ -165,20 +173,27 @@ export async function handleInboundEmail( const atIndex = recipient.lastIndexOf('@') const localPart = recipient.slice(0, atIndex) const recipientDomain = recipient.slice(atIndex + 1) - if (recipientDomain !== platformDomain) { - message.setReject('Unknown Kody email address.') - return - } - if (isSystemEmailLocal(localPart)) { + // Operator-owned system inboxes live on the apex domain, next to the + // kody@ transactional sender whose replies they receive. User mail + // lives exclusively on the user subdomain; all other apex mail rejects. + if ( + systemDomain && + recipientDomain === systemDomain && + isSystemEmailLocal(localPart) + ) { await handleSystemInboundEmail({ message, env, recipient, localPart, - platformDomain, + systemDomain, }) return } + if (!platformDomain || recipientDomain !== platformDomain) { + message.setReject('Unknown Kody email address.') + return + } if (isReservedUsername(localPart)) { message.setReject('This address is reserved for system mail.') return @@ -375,12 +390,12 @@ async function handleSystemInboundEmail(input: { > recipient: string localPart: SystemEmailLocal - platformDomain: string + systemDomain: string }) { const provisioned = await ensureSystemEmailInbox({ db: input.env.APP_DB, localPart: input.localPart, - domain: input.platformDomain, + domain: input.systemDomain, }) if (!provisioned) { input.message.setReject('Email inbox is unavailable.') diff --git a/packages/worker/src/email/inbound.workers.test.ts b/packages/worker/src/email/inbound.workers.test.ts index 0410d51b43..fb96342f15 100644 --- a/packages/worker/src/email/inbound.workers.test.ts +++ b/packages/worker/src/email/inbound.workers.test.ts @@ -18,7 +18,8 @@ import { buildPublishedSourceManifestSnapshotKvKey } from '#worker/package-runti import { createStableUserIdFromEmail } from '#worker/user-id.ts' const platformBaseUrl = 'https://kody.example.com' -const platformDomain = 'kody.example.com' +// User mail lives on the inbox. subdomain derived from APP_BASE_URL. +const platformDomain = 'inbox.kody.example.com' function createInboundEnv() { return { ...env, APP_BASE_URL: platformBaseUrl } @@ -214,6 +215,20 @@ test('inbound email rejects unknown usernames, reserved locals, and foreign doma to: 'someone@other.example.com', reason: 'Unknown Kody email address.', }) + // The app's apex domain is not a user inbox either: user mail lives + // exclusively on the inbox. subdomain; the apex hosts only system mail. + { + const apexUsername = `apex-${crypto.randomUUID().slice(0, 8)}` + await seedVerifiedAccount({ + db: env.APP_DB, + email: `apex-${crypto.randomUUID()}@example.com`, + username: apexUsername, + }) + await expectRejected({ + to: `${apexUsername}@kody.example.com`, + reason: 'Unknown Kody email address.', + }) + } // A disabled legacy row holding the platform address makes the inbox // unavailable (clean reject) instead of crashing provisioning on the diff --git a/packages/worker/src/email/outbound.ts b/packages/worker/src/email/outbound.ts index 7e5f34eed2..1bce41dd01 100644 --- a/packages/worker/src/email/outbound.ts +++ b/packages/worker/src/email/outbound.ts @@ -23,6 +23,7 @@ type SendEmailEnv = Pick< | 'EMAIL' | 'USAGE_EVENTS' | 'APP_BASE_URL' + | 'USER_EMAIL_DOMAIN' | 'CLOUDFLARE_ACCOUNT_ID' | 'CLOUDFLARE_API_BASE_URL' | 'CLOUDFLARE_API_TOKEN' diff --git a/packages/worker/src/email/outbound.workers.test.ts b/packages/worker/src/email/outbound.workers.test.ts index c95d279a61..508c36c7bf 100644 --- a/packages/worker/src/email/outbound.workers.test.ts +++ b/packages/worker/src/email/outbound.workers.test.ts @@ -25,7 +25,8 @@ const cloudflareEmailApi = 'https://api.cloudflare.test/client/v4/accounts/account-123/email/sending/send' const platformBaseUrl = 'https://kody.example.com' -const platformDomain = 'kody.example.com' +// User mail lives on the inbox. subdomain derived from APP_BASE_URL. +const platformDomain = 'inbox.kody.example.com' function createBindingSendEnv() { return { diff --git a/packages/worker/src/email/platform-address.node.test.ts b/packages/worker/src/email/platform-address.node.test.ts new file mode 100644 index 0000000000..191a960db8 --- /dev/null +++ b/packages/worker/src/email/platform-address.node.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest' +import { + buildPlatformEmailAddress, + getPlatformEmailDomain, +} from './platform-address.ts' + +test('getPlatformEmailDomain derives inbox. from APP_BASE_URL', () => { + expect(getPlatformEmailDomain({ APP_BASE_URL: 'https://heykody.dev' })).toBe( + 'inbox.heykody.dev', + ) + expect( + getPlatformEmailDomain({ APP_BASE_URL: 'https://Staging.Example.COM/' }), + ).toBe('inbox.staging.example.com') + expect(getPlatformEmailDomain({})).toBeNull() + expect(getPlatformEmailDomain({ APP_BASE_URL: 'not a url' })).toBeNull() +}) + +test('getPlatformEmailDomain prefers a valid USER_EMAIL_DOMAIN override', () => { + expect( + getPlatformEmailDomain({ + APP_BASE_URL: 'https://heykody.dev', + USER_EMAIL_DOMAIN: 'Mail.Example.COM.', + }), + ).toBe('mail.example.com') + // The override works without APP_BASE_URL too. + expect( + getPlatformEmailDomain({ USER_EMAIL_DOMAIN: 'inbox.heykody.dev' }), + ).toBe('inbox.heykody.dev') + // A malformed override falls back to the derived default. + expect( + getPlatformEmailDomain({ + APP_BASE_URL: 'https://heykody.dev', + USER_EMAIL_DOMAIN: 'not a hostname', + }), + ).toBe('inbox.heykody.dev') + expect(getPlatformEmailDomain({ USER_EMAIL_DOMAIN: 'user@host' })).toBeNull() +}) + +test('buildPlatformEmailAddress normalizes the username', () => { + expect( + buildPlatformEmailAddress({ + username: ' KentCDodds ', + domain: 'inbox.heykody.dev', + }), + ).toBe('kentcdodds@inbox.heykody.dev') +}) diff --git a/packages/worker/src/email/platform-address.ts b/packages/worker/src/email/platform-address.ts index c8b961f36b..d15415bd41 100644 --- a/packages/worker/src/email/platform-address.ts +++ b/packages/worker/src/email/platform-address.ts @@ -3,19 +3,65 @@ import { isReservedUsername } from '#app/reserved-usernames.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' /** - * The email domain every user inbox and user outbound sender lives on: - * the hostname of `APP_BASE_URL` (for example `heykody.dev`). Returns null - * when the deployment has no configured base URL, in which case user email + * The label prepended to the app hostname to form the default user email + * domain (`inbox.heykody.dev` for `APP_BASE_URL` `https://heykody.dev`). + */ +export const defaultUserEmailSubdomainLabel = 'inbox' + +const bareHostnamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/ + +/** + * The email domain operator-owned system mail lives on: the hostname of + * `APP_BASE_URL` (for example `heykody.dev`). The transactional sender + * (`kody@`) and the operator system inboxes both use this apex + * domain, keeping them structurally separate from the user subdomain. + */ +export function getSystemEmailDomain(env: { + APP_BASE_URL?: string | null +}): string | null { + const configuredBaseUrl = env.APP_BASE_URL?.trim() + if (!configuredBaseUrl) return null + try { + const hostname = new URL(configuredBaseUrl).hostname.toLowerCase() + return hostname.length > 0 ? hostname : null + } catch { + return null + } +} + +/** + * The email domain every user inbox and user outbound sender lives on: the + * `USER_EMAIL_DOMAIN` env var when set, otherwise `inbox.` plus the + * hostname of `APP_BASE_URL` (for example `inbox.heykody.dev`). + * + * User mail deliberately lives on a dedicated subdomain, not the apex: the + * user-controlled address namespace stays structurally separate from system + * mail on the apex (the `kody@` transactional sender and the operator + * system inboxes), and user outbound sender reputation (SPF/DKIM/DMARC) is + * isolated from the system sender's. + * + * Returns null when nothing is configured, in which case user email * addressing is unavailable. */ export function getPlatformEmailDomain(env: { APP_BASE_URL?: string | null + USER_EMAIL_DOMAIN?: string | null }): string | null { + const configuredDomain = env.USER_EMAIL_DOMAIN?.trim() + .toLowerCase() + .replace(/\.$/, '') + if (configuredDomain && bareHostnamePattern.test(configuredDomain)) { + return configuredDomain + } + // Runtime env validation should already catch a malformed override; fall + // back to the derived default defensively (same as getAppBaseUrl). const configuredBaseUrl = env.APP_BASE_URL?.trim() if (!configuredBaseUrl) return null try { const hostname = new URL(configuredBaseUrl).hostname.toLowerCase() - return hostname.length > 0 ? hostname : null + return hostname.length > 0 + ? `${defaultUserEmailSubdomainLabel}.${hostname}` + : null } catch { return null } diff --git a/packages/worker/src/email/system-email.workers.test.ts b/packages/worker/src/email/system-email.workers.test.ts index 022ac8cc9f..7780af7906 100644 --- a/packages/worker/src/email/system-email.workers.test.ts +++ b/packages/worker/src/email/system-email.workers.test.ts @@ -17,7 +17,9 @@ import { ensureUsageRollupsTestSchema } from '#worker/usage/test-schema.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' const platformBaseUrl = 'https://kody.example.com' -const platformDomain = 'kody.example.com' +// System inboxes live on the apex; user mail lives on the inbox. subdomain. +const systemDomain = 'kody.example.com' +const userDomain = 'inbox.kody.example.com' function createInboundEnv() { return { ...env, APP_BASE_URL: platformBaseUrl } @@ -86,7 +88,7 @@ test('reserved system locals store under the operator-owned system inbox', async await seedVerifiedAccount({ email: legacyEmail, username: 'kody' }) const message = buildInboundMessage({ - to: `kody@${platformDomain}`, + to: `kody@${systemDomain}`, subject: 'Cloudflare confirmation', }) await handleInboundEmail(message, createInboundEnv()) @@ -132,15 +134,30 @@ test('non-system reserved locals still reject while username addresses are unaff await seedVerifiedAccount({ email, username }) const reservedMessage = buildInboundMessage({ - to: `help@${platformDomain}`, + to: `help@${userDomain}`, }) await handleInboundEmail(reservedMessage, createInboundEnv()) expect(reservedMessage.rejectedReason).toBe( 'This address is reserved for system mail.', ) + // System locals only route on the apex: on the user subdomain they stay + // reserved, and non-system locals on the apex are not addresses at all. + const subdomainSystemLocal = buildInboundMessage({ + to: `kody@${userDomain}`, + }) + await handleInboundEmail(subdomainSystemLocal, createInboundEnv()) + expect(subdomainSystemLocal.rejectedReason).toBe( + 'This address is reserved for system mail.', + ) + const apexNonSystemLocal = buildInboundMessage({ + to: `${username}@${systemDomain}`, + }) + await handleInboundEmail(apexNonSystemLocal, createInboundEnv()) + expect(apexNonSystemLocal.rejectedReason).toBe('Unknown Kody email address.') + const userMessage = buildInboundMessage({ - to: `${username}@${platformDomain}`, + to: `${username}@${userDomain}`, subject: 'User inbox still works', }) await handleInboundEmail(userMessage, createInboundEnv()) @@ -154,7 +171,7 @@ test('system email size and daily caps reject before storage with bounded events await ensureEmailTestSchema(env.APP_DB) await ensureUsageRollupsTestSchema(env.APP_DB) - const oversize = buildInboundMessage({ to: `abuse@${platformDomain}` }) + const oversize = buildInboundMessage({ to: `abuse@${systemDomain}` }) Object.defineProperty(oversize, 'rawSize', { value: systemEmailLimits.maxMessageBytes + 1, }) @@ -184,7 +201,7 @@ test('system email size and daily caps reject before storage with bounded events const attempts = maxDetailedEmailRejectionEventsPerDay + 2 for (let index = 0; index < attempts; index += 1) { const capped = buildInboundMessage({ - to: `support@${platformDomain}`, + to: `support@${systemDomain}`, messageId: `system-cap-${index}`, }) await handleInboundEmail(capped, createInboundEnv()) diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts index 062409c97b..b648331ae3 100644 --- a/packages/worker/src/env-schema.ts +++ b/packages/worker/src/env-schema.ts @@ -171,6 +171,7 @@ export const EnvSchema = object({ 'Missing PACKAGE_SERVICE_INSTANCE binding for package service runtimes.', ), APP_BASE_URL: optionalUrlStringSchema, + USER_EMAIL_DOMAIN: optionalNonEmptyStringSchema, APP_COMMIT_SHA: optionalCommitShaSchema, EMAIL: optionalSendEmailSchema, USAGE_EVENTS: optionalAnalyticsEngineDatasetSchema, From bb3010fdc582cbf9a5a8e6811879ac97fe23e425 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 15:51:16 +0000 Subject: [PATCH 2/2] docs: user mail lives on the configured platform domain The USER_EMAIL_DOMAIN override means user mail is not always on a subdomain of APP_BASE_URL; describe the configured domain instead. --- docs/use/email-primitives.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/use/email-primitives.md b/docs/use/email-primitives.md index 9849a13471..588b2e453b 100644 --- a/docs/use/email-primitives.md +++ b/docs/use/email-primitives.md @@ -15,12 +15,13 @@ platform-assigned sender address. that username. The default inbox is provisioned automatically at signup (or on the first inbound message), so there is nothing to create or configure. - Mail to unknown usernames is rejected, and the app's apex domain is never a - user inbox: user mail lives exclusively on the dedicated subdomain, while the - apex hosts only system mail — the transactional sender (`kody@`, used - for verification and password-reset mail) and the operator-owned system - inboxes (`kody`, `support`, `abuse`, `postmaster`, `security`, and `admin` at - the apex route to Kody's system inbox, so replies to transactional mail land - there). All other apex mail is rejected. + 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@`, used for verification and password-reset + mail) and the operator-owned system inboxes (`kody`, `support`, `abuse`, + `postmaster`, `security`, and `admin` at the apex route to Kody's system + inbox, so replies to transactional mail land there). 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}@`. The from