Skip to content

Move user email addressing to the inbox. subdomain - #643

Merged
kentcdodds merged 2 commits into
mainfrom
cursor/auto-username-email-inbox-8ab0
Jul 6, 2026
Merged

kentcdodds merged 2 commits into
mainfrom
cursor/auto-username-email-inbox-8ab0

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 6, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #635, per product owner decision: user email addressing moves off the zone apex onto a dedicated subdomain. User inboxes and outbound senders now live at {username}@inbox.<APP_BASE_URL hostname> — kentcdodds@inbox.heykody.dev in production — with an optional USER_EMAIL_DOMAIN env override.

Rebased on main after #642 (operator-owned system inboxes) landed; the two now compose into a clean two-domain model:

Domain Purpose
heykody.dev (apex, getSystemEmailDomain) System mail only: kody@ transactional sender + operator system inboxes (kody, support, abuse, postmaster, security, admin). All other apex mail rejects.
inbox.heykody.dev (getPlatformEmailDomain) User mail only: {username}@ inboxes and outbound senders. System/reserved locals reject here.

Why

The apex model relied on the reserved-username denylist alone to keep system addresses safe; a denylist is inherently incomplete. A dedicated subdomain makes the separation structural:

  • The user-controlled address namespace can never collide with system mail — the apex is no longer a user inbox at all.
  • User outbound sender reputation (SPF/DKIM/DMARC) is isolated from the system sender's.
  • The reserved-username denylist stays — it still guards signup, URLs, and package scopes, and the inbound reserved-local check remains as defense-in-depth (system locals on the user subdomain reject as reserved).

Cloudflare Email Routing supports subdomains on all plans (free), so this is dashboard config only on the infra side.

What changed

  • getPlatformEmailDomain (the single chokepoint from HARD BREAK: username-based email inbox model #635) now returns USER_EMAIL_DOMAIN when set, otherwise inbox. + the APP_BASE_URL hostname. Inbound user routing, the outbound from address, default-inbox auto-provisioning, and sender-identity provisioning all follow from this one derivation change.
  • New getSystemEmailDomain (apex hostname): handleInboundEmail routes Add operator-owned system email inboxes #642's system locals on the apex (next to the transactional sender whose replies they receive), user mail on the subdomain, and rejects everything else on either domain.
  • USER_EMAIL_DOMAIN added to the env schema, .env.example, and docs/contributing/environment-variables.md. A malformed override falls back to the derived default (same defensive pattern as getAppBaseUrl). Production needs no config: the derived default yields inbox.heykody.dev.
  • Docs: email-primitives.md addressing model (merged with Add operator-owned system email inboxes #642's system-inbox wording) + local-testing example updated.

Tests

  • New platform-address.node.test.ts: derived default, override normalization (case/trailing dot), malformed-override fallback, null when unconfigured.
  • Email workers suites run against inbox.kody.example.com; system-email.workers.test.ts splits addresses across apex (system) and subdomain (user) and adds cross-domain cases: system locals on the subdomain reject as reserved, non-system locals on the apex reject as unknown, and a real username on the apex rejects.
  • npm run validate green locally after the rebase.

Deploy ordering (important)

Before (or immediately after) this deploys, the Cloudflare zone needs Email Routing enabled for the subdomain: Email → Email Routing → Settings → Add subdomain (inbox.heykody.dev), accept the DNS records, and point the subdomain's catch-all at the worker. Until that's done, inbound user mail has nowhere to land (apex user-mail is rejected by this change; subdomain mail isn't routed yet). Outbound from kentcdodds@inbox.heykody.dev also depends on the subdomain's sending DNS being in place. System inboxes on the apex keep working unchanged.

System recap — extends existing primitives (medium risk)

Mode: recap · Base: main @ 148a49c7 · Head: 498697b9

Classification: extends — the email primitive's addressing contract changes again (user mail: apex → inbox. subdomain); intentionally breaking for the ~hours-old apex user addresses, accepted pre-launch.

Primitives touched

Primitive Group Impact
email assistant extends — user domain becomes inbox.<apex> (or USER_EMAIL_DOMAIN); apex = system mail only

System map

flowchart LR
	cfUser["Email Routing: inbox.heykody.dev"]:::untouched
	cfApex["Email Routing: heykody.dev (apex)"]:::untouched
	email["email (user addressing)"]:::extended
	systemEmail["system-email inboxes (#642)"]:::touched
	entitlements["entitlements"]:::untouched
	cfUser --> email
	cfApex --> systemEmail
	email --> entitlements
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Before / after

User inbox:    {username}@heykody.dev            →  {username}@inbox.heykody.dev
Outbound from: {username}@heykody.dev            →  {username}@inbox.heykody.dev
Apex domain:   user inboxes + system mail        →  system mail ONLY (transactional sender + operator inboxes)
Config:        domain = APP_BASE_URL hostname    →  USER_EMAIL_DOMAIN ?? inbox.<APP_BASE_URL hostname>
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added optional USER_EMAIL_DOMAIN to override the user inbox domain (inbox.<app hostname> by default).
    • Updated email routing to distinguish user inbox mail (subdomain) from system/transactional mail (apex).
  • Bug Fixes
    • Improved handling of inbound addresses so unknown usernames, reserved local parts, and domain mismatches are rejected consistently.
  • Documentation
    • Updated environment setup and email addressing/local testing docs to reflect the new inbox and override behavior.
  • Tests
    • Refreshed inbound/outbound routing tests to use the new default inbox subdomain and expectations.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5725975e-dccb-4ec8-af95-4320b540f30b

📥 Commits

Reviewing files that changed from the base of the PR and between 498697b and bb3010f.

📒 Files selected for processing (1)
  • docs/use/email-primitives.md
✅ Files skipped from review due to trivial changes (1)
  • docs/use/email-primitives.md

📝 Walkthrough

Walkthrough

This PR adds USER_EMAIL_DOMAIN as an optional inbox-domain override, separates user and system email domain resolution, updates inbound and outbound email handling to use the split domains, and revises tests and docs to match the new routing model.

Changes

USER_EMAIL_DOMAIN support and domain split

Layer / File(s) Summary
Environment schema and config additions
packages/worker/src/env-schema.ts, packages/worker/.env.example, docs/contributing/environment-variables.md
USER_EMAIL_DOMAIN is added to the worker env schema and documented in example and contributor configuration docs.
Domain resolution helpers
packages/worker/src/email/platform-address.ts, packages/worker/src/email/platform-address.node.test.ts
User-domain overrides are validated and normalized, default inbox domains are derived from APP_BASE_URL, and a separate system apex-domain helper is added with tests.
Inbound email routing with system/user domains
packages/worker/src/email/inbound.ts, packages/worker/src/email/inbound.workers.test.ts, packages/worker/src/email/inbound-entitlements.workers.test.ts
Inbound handling now computes both domains, routes system locals only on the apex domain, rejects mismatches, and updates tests for the new recipient domain behavior.
Outbound env requirement and system email tests
packages/worker/src/email/outbound.ts, packages/worker/src/email/outbound.workers.test.ts, packages/worker/src/email/system-email.workers.test.ts
Outbound env typing now includes USER_EMAIL_DOMAIN, and system-email tests use separate apex and inbox subdomain fixtures with updated assertions.
User-facing documentation updates
docs/use/email-primitives.md
Email addressing docs now describe the new default/override platform domain, apex-only system mail behavior, and updated inbound testing examples.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

  • kentcdodds/kody#635: Touches the same email domain resolution and inbound routing code paths now extended with a user-domain override and system-domain split.
  • kentcdodds/kody#636: Also changes packages/worker/src/email/inbound.ts, though for inbound entitlement and quota handling rather than domain routing.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving user email addressing to the inbox subdomain.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/auto-username-email-inbox-8ab0

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-643.kentcdodds.workers.dev

Worker: kody-pr-643
D1: kody-pr-643-db
KV: kody-pr-643-oauth-kv

Mocks:

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 473e69e. Configure here.

Comment thread packages/worker/src/email/platform-address.ts
User inboxes and outbound senders now live at
{username}@inbox.<APP_BASE_URL hostname> (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@<apex> 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.
@cursor
cursor Bot force-pushed the cursor/auto-username-email-inbox-8ab0 branch from 473e69e to 498697b Compare July 6, 2026 15:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
packages/worker/src/email/platform-address.ts (2)

46-67: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

No guard against USER_EMAIL_DOMAIN colliding with the system/apex domain.

If an operator sets USER_EMAIL_DOMAIN to the same hostname as APP_BASE_URL's apex, getPlatformEmailDomain and getSystemEmailDomain return identical values, silently collapsing the structural separation between user and system mail that this PR introduces (reserved-username denylist still protects known system locals, but the sender-reputation/namespace isolation goal is defeated without any warning). Consider rejecting (or logging/warning on) an override equal to the system domain and falling back to the derived default in that case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/platform-address.ts` around lines 46 - 67,
`getPlatformEmailDomain` currently accepts `USER_EMAIL_DOMAIN` even when it
matches the apex/system domain derived from `APP_BASE_URL`, which can make
`getSystemEmailDomain` and the platform domain collide; update this function to
detect that equality and reject or warn on the override, then fall back to the
derived default instead of returning the conflicting value. Use the existing
`configuredDomain`, `configuredBaseUrl`, and `defaultUserEmailSubdomainLabel`
logic in `platform-address.ts` to locate the check and keep the user/system mail
namespaces separated.

19-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated APP_BASE_URL hostname-parsing logic between getSystemEmailDomain and getPlatformEmailDomain.

Both functions independently do new URL(configuredBaseUrl).hostname.toLowerCase() with the same empty-check/try-catch shape. Since these two functions define the system/user domain split this PR is built around, keeping the parsing in one place avoids future drift between them.

♻️ Suggested shared helper
+function deriveHostnameFromAppBaseUrl(appBaseUrl?: string | null): string | null {
+	const configuredBaseUrl = appBaseUrl?.trim()
+	if (!configuredBaseUrl) return null
+	try {
+		const hostname = new URL(configuredBaseUrl).hostname.toLowerCase()
+		return hostname.length > 0 ? hostname : null
+	} catch {
+		return null
+	}
+}
+
 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
-	}
+	return deriveHostnameFromAppBaseUrl(env.APP_BASE_URL)
 }

Also applies to: 46-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/platform-address.ts` around lines 19 - 30, The
hostname parsing for APP_BASE_URL is duplicated in getSystemEmailDomain and
getPlatformEmailDomain, so refactor the shared URL-to-hostname logic into a
single helper and have both functions call it. Preserve the current trim,
empty-check, lowercase conversion, and try/catch behavior in the shared helper
so both domain split functions stay consistent and avoid drift.
packages/worker/src/email/platform-address.node.test.ts (1)

1-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing unit test for getSystemEmailDomain.

The new getSystemEmailDomain export (used to gate system-inbox routing in inbound.ts) isn't unit-tested in this file; only getPlatformEmailDomain and buildPlatformEmailAddress are covered. Worker-level tests exercise it indirectly, but a direct unit test here would pin down its edge cases (missing/malformed APP_BASE_URL) alongside its sibling.

✅ Suggested test
+test('getSystemEmailDomain derives the apex hostname from APP_BASE_URL', () => {
+	expect(getSystemEmailDomain({ APP_BASE_URL: 'https://heykody.dev' })).toBe(
+		'heykody.dev',
+	)
+	expect(getSystemEmailDomain({})).toBeNull()
+	expect(getSystemEmailDomain({ APP_BASE_URL: 'not a url' })).toBeNull()
+})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/platform-address.node.test.ts` around lines 1 - 46,
Add a direct unit test for getSystemEmailDomain in this spec alongside
getPlatformEmailDomain and buildPlatformEmailAddress. Cover the key edge cases
called out in inbound.ts routing: valid APP_BASE_URL should derive the expected
system inbox domain, while missing or malformed APP_BASE_URL should return null.
Use the existing test style in platform-address.node.test.ts and reference
getSystemEmailDomain explicitly so the behavior is pinned down independently of
worker-level coverage.
packages/worker/src/env-schema.ts (1)

174-174: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Env schema doesn't enforce hostname format for USER_EMAIL_DOMAIN.

optionalNonEmptyStringSchema only checks non-emptiness, unlike APP_BASE_URL's optionalUrlStringSchema which enforces actual URL parseability. A malformed USER_EMAIL_DOMAIN (e.g. containing spaces) passes this schema silently, and is only caught defensively later in getPlatformEmailDomain's bareHostnamePattern check — contradicting that function's inline comment claiming parity with APP_BASE_URL's validation. Given getEnv() throws at boot on schema failures with a clear message, tightening this schema (e.g. reusing the hostname regex) would fail fast on misconfiguration instead of silently falling back to the derived default.

♻️ Suggested tightened schema
-	USER_EMAIL_DOMAIN: optionalNonEmptyStringSchema,
+	USER_EMAIL_DOMAIN: createSchema<unknown, string | undefined>((value, context) => {
+		if (value === undefined) return { value: undefined }
+		if (typeof value !== 'string') return fail('Expected string', context.path)
+		const trimmed = value.trim().toLowerCase().replace(/\.$/, '')
+		if (trimmed.length === 0) return { value: undefined }
+		if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(trimmed)) {
+			return fail('USER_EMAIL_DOMAIN must be a bare hostname.', context.path)
+		}
+		return { value: trimmed }
+	}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/env-schema.ts` at line 174, USER_EMAIL_DOMAIN currently
uses only a non-empty string check, so it should be tightened to validate
hostname format at the schema layer instead of relying on later fallback logic.
Update the env schema in env-schema.ts by replacing optionalNonEmptyStringSchema
for USER_EMAIL_DOMAIN with the same hostname-style validation used by
getPlatformEmailDomain/bareHostnamePattern, so getEnv() fails fast on malformed
values and stays consistent with APP_BASE_URL’s stricter parsing.
packages/worker/src/email/system-email.workers.test.ts (1)

20-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared domain test fixtures.

systemDomain/userDomain/platformBaseUrl constants are now duplicated near-identically across this file, inbound.workers.test.ts, inbound-entitlements.workers.test.ts, and outbound.workers.test.ts. A shared test-fixtures module could reduce drift risk if the derivation logic changes again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/system-email.workers.test.ts` around lines 20 - 22,
The domain fixture constants are duplicated across multiple worker test files,
which makes them easy to drift apart if the derivation logic changes. Extract
the shared `systemDomain`, `userDomain`, and `platformBaseUrl` setup into a
common test-fixtures module and update `system-email.workers.test.ts` to import
and use that shared source instead of local copies. Keep the existing test names
and assertions intact, but centralize the derivation logic so
`inbound.workers.test.ts`, `inbound-entitlements.workers.test.ts`, and
`outbound.workers.test.ts` can all reuse the same fixture helpers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/use/email-primitives.md`:
- Around line 14-25: Update the email primitives docs bullet so it describes the
configured platform domain instead of implying a dedicated subdomain; in the
section covering inbound mail and unknown usernames, rephrase the user-inbox
routing text in docs/use/email-primitives.md to reference the platform domain /
USER_EMAIL_DOMAIN behavior, using the existing terms like “platform domain,”
“apex,” and “system inboxes” consistently.

---

Nitpick comments:
In `@packages/worker/src/email/platform-address.node.test.ts`:
- Around line 1-46: Add a direct unit test for getSystemEmailDomain in this spec
alongside getPlatformEmailDomain and buildPlatformEmailAddress. Cover the key
edge cases called out in inbound.ts routing: valid APP_BASE_URL should derive
the expected system inbox domain, while missing or malformed APP_BASE_URL should
return null. Use the existing test style in platform-address.node.test.ts and
reference getSystemEmailDomain explicitly so the behavior is pinned down
independently of worker-level coverage.

In `@packages/worker/src/email/platform-address.ts`:
- Around line 46-67: `getPlatformEmailDomain` currently accepts
`USER_EMAIL_DOMAIN` even when it matches the apex/system domain derived from
`APP_BASE_URL`, which can make `getSystemEmailDomain` and the platform domain
collide; update this function to detect that equality and reject or warn on the
override, then fall back to the derived default instead of returning the
conflicting value. Use the existing `configuredDomain`, `configuredBaseUrl`, and
`defaultUserEmailSubdomainLabel` logic in `platform-address.ts` to locate the
check and keep the user/system mail namespaces separated.
- Around line 19-30: The hostname parsing for APP_BASE_URL is duplicated in
getSystemEmailDomain and getPlatformEmailDomain, so refactor the shared
URL-to-hostname logic into a single helper and have both functions call it.
Preserve the current trim, empty-check, lowercase conversion, and try/catch
behavior in the shared helper so both domain split functions stay consistent and
avoid drift.

In `@packages/worker/src/email/system-email.workers.test.ts`:
- Around line 20-22: The domain fixture constants are duplicated across multiple
worker test files, which makes them easy to drift apart if the derivation logic
changes. Extract the shared `systemDomain`, `userDomain`, and `platformBaseUrl`
setup into a common test-fixtures module and update
`system-email.workers.test.ts` to import and use that shared source instead of
local copies. Keep the existing test names and assertions intact, but centralize
the derivation logic so `inbound.workers.test.ts`,
`inbound-entitlements.workers.test.ts`, and `outbound.workers.test.ts` can all
reuse the same fixture helpers.

In `@packages/worker/src/env-schema.ts`:
- Line 174: USER_EMAIL_DOMAIN currently uses only a non-empty string check, so
it should be tightened to validate hostname format at the schema layer instead
of relying on later fallback logic. Update the env schema in env-schema.ts by
replacing optionalNonEmptyStringSchema for USER_EMAIL_DOMAIN with the same
hostname-style validation used by getPlatformEmailDomain/bareHostnamePattern, so
getEnv() fails fast on malformed values and stays consistent with APP_BASE_URL’s
stricter parsing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b0bf628-be7c-49da-817f-286145470a88

📥 Commits

Reviewing files that changed from the base of the PR and between 148a49c and 498697b.

📒 Files selected for processing (12)
  • docs/contributing/environment-variables.md
  • docs/use/email-primitives.md
  • packages/worker/.env.example
  • packages/worker/src/email/inbound-entitlements.workers.test.ts
  • packages/worker/src/email/inbound.ts
  • packages/worker/src/email/inbound.workers.test.ts
  • packages/worker/src/email/outbound.ts
  • packages/worker/src/email/outbound.workers.test.ts
  • packages/worker/src/email/platform-address.node.test.ts
  • packages/worker/src/email/platform-address.ts
  • packages/worker/src/email/system-email.workers.test.ts
  • packages/worker/src/env-schema.ts

Comment thread docs/use/email-primitives.md
The USER_EMAIL_DOMAIN override means user mail is not always on a
subdomain of APP_BASE_URL; describe the configured domain instead.
@kentcdodds
kentcdodds merged commit cc9a927 into main Jul 6, 2026
5 checks passed
@kentcdodds
kentcdodds deleted the cursor/auto-username-email-inbox-8ab0 branch July 6, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants