Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 19 additions & 15 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}@<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 dedicated subdomain, 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 +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}@<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