Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<APP_BASE_URL hostname>` (for example `inbox.heykody.dev`): every user
inbox and user outbound sender lives at `{username}@<this domain>`. User mail
deliberately lives on a subdomain so the user-controlled namespace and its
sender reputation stay separate from system transactional mail
(`kody@<apex>`). 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
Expand Down
35 changes: 20 additions & 15 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@

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>` (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}@<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.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}@<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.
- 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 configured platform domain (the
`inbox.` subdomain by default), while the apex hosts only system mail — the
transactional sender (`kody@<apex>`, 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@<platform domain>` 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- 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
Expand Down Expand Up @@ -174,14 +178,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}@<APP_BASE_URL hostname>`. The local worker defaults to port `3742`
unless you set `PORT`:
`{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`:

```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 <sender@example.com>
To: Username <username@example.com>
To: Username <username@inbox.example.com>
Subject: Hello
Message-ID: <hello@example.com>

Expand Down
6 changes: 6 additions & 0 deletions packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ SECRET_STORE_KEY=LOCAL_SECRET_STORE_KEY_32_CHARS_MINIMUM_VALUE
# from kody@<APP_BASE_URL hostname> when set)
APP_BASE_URL=

# Optional override for the user email domain. Defaults to
# inbox.<APP_BASE_URL hostname> (e.g. inbox.heykody.dev): every user inbox
# and user outbound sender lives at {username}@<this domain>, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
37 changes: 26 additions & 11 deletions packages/worker/src/email/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
) {
Expand All @@ -157,28 +164,36 @@ 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
}

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@<apex> 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
Expand Down Expand Up @@ -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.')
Expand Down
17 changes: 16 additions & 1 deletion packages/worker/src/email/inbound.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/email/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/src/email/outbound.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions packages/worker/src/email/platform-address.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from 'vitest'
import {
buildPlatformEmailAddress,
getPlatformEmailDomain,
} from './platform-address.ts'

test('getPlatformEmailDomain derives inbox.<hostname> 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')
})
54 changes: 50 additions & 4 deletions packages/worker/src/email/platform-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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@<apex>`) 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@<apex>` 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(/\.$/, '')
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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
}
Expand Down
Loading
Loading