diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index 35c3cbd37a..f70fa87d24 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -350,9 +350,12 @@ Optional Worker secrets/vars (see `packages/worker/src/env-schema.ts` and - `SYSTEM_EMAIL_DOMAIN` — optional override for the system email domain (the `kody@` transactional sender and operator system inboxes). Defaults to the `APP_BASE_URL` hostname. Production commits - `SYSTEM_EMAIL_DOMAIN=kody.codes`. Signup, email-change, and password-reset - messages also put this host on their action and asset links. Local - `npm run dev` keeps those links on the request origin so they stay clickable. + `SYSTEM_EMAIL_DOMAIN=kody.codes`. Signup, email-change, password-reset, and + entitlement-warning messages send from that host and put action and asset + links on the worker origin (`APP_BASE_URL`). A leftover `heykody.app` / + `heykody.dev` worker origin remaps both From and links to this override (or + `kody.codes` when the override is also retired). Local `npm run dev` keeps + those links on the request origin so they stay clickable. - `LEGACY_USER_EMAIL_DOMAINS` / `LEGACY_SYSTEM_EMAIL_DOMAINS` — optional comma-separated additional email domains that inbound mail is accepted on alongside the canonical domains (see diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md index e3598bb288..8421b03bd4 100644 --- a/docs/contributing/setup.md +++ b/docs/contributing/setup.md @@ -46,11 +46,12 @@ Quick notes for getting a local kody environment running. clone/pull/push flows need a real Git-capable Artifacts remote and are not fully simulated by the local mock. Password reset and email-verification messages send through the same Cloudflare Email API helper. Both send from - `kody@` (falling back to the `APP_BASE_URL` hostname) and - put that same sending domain on action and asset links, so a legacy - `APP_BASE_URL` cannot pin `heykody.dev` into the message. Local `npm run dev` - keeps those action and asset links on the request origin so they stay - clickable. Set `SKIP_CLOUDFLARE_MOCK=1` to skip the local Cloudflare mock + `kody@` (falling back to the worker's `APP_BASE_URL` + hostname) and put action and asset links on that worker origin. A leftover + `heykody.app` / `heykody.dev` origin remaps to `SYSTEM_EMAIL_DOMAIN` when set, + otherwise `kody.codes`, so live mail cannot keep the retired hosts. Local + `npm run dev` keeps those action and asset links on the request origin so they + stay clickable. Set `SKIP_CLOUDFLARE_MOCK=1` to skip the local Cloudflare mock entirely. The main worker streams logs live; the client bundle and background mock workers buffer logs and only print them if that child process exits with an error. diff --git a/packages/worker/src/app/email/sender-config.node.test.ts b/packages/worker/src/app/email/sender-config.node.test.ts index 50f73a13f8..8027aa9778 100644 --- a/packages/worker/src/app/email/sender-config.node.test.ts +++ b/packages/worker/src/app/email/sender-config.node.test.ts @@ -1,14 +1,13 @@ import { expect, test } from 'vitest' import { resolveTransactionalEmailConfig } from './sender-config.ts' -test('resolveTransactionalEmailConfig derives link hosts from the sending domain', () => { +test('resolveTransactionalEmailConfig follows the worker origin and remaps leftover heykody hosts', () => { expect( resolveTransactionalEmailConfig({ env: { - APP_BASE_URL: 'https://heykody.dev', + APP_BASE_URL: 'https://kody.codes', SYSTEM_EMAIL_DOMAIN: 'kody.codes', }, - requestUrl: 'https://heykody.dev/signup', }), ).toEqual({ appBaseUrl: 'https://kody.codes', @@ -17,25 +16,47 @@ test('resolveTransactionalEmailConfig derives link hosts from the sending domain expect( resolveTransactionalEmailConfig({ - env: { APP_BASE_URL: 'https://app.example.com/path' }, + env: { APP_BASE_URL: 'https://heykody.app/' }, }), ).toEqual({ - appBaseUrl: 'https://app.example.com', - fromEmail: 'kody@app.example.com', + appBaseUrl: 'https://kody.codes', + fromEmail: 'kody@kody.codes', }) expect( resolveTransactionalEmailConfig({ env: { - APP_BASE_URL: 'https://kody.codes', + APP_BASE_URL: 'https://heykody.dev', SYSTEM_EMAIL_DOMAIN: 'kody.codes', }, + requestUrl: 'https://heykody.dev/signup', }), ).toEqual({ appBaseUrl: 'https://kody.codes', fromEmail: 'kody@kody.codes', }) + expect( + resolveTransactionalEmailConfig({ + env: { + APP_BASE_URL: 'https://kody-pr-1708.example.workers.dev', + SYSTEM_EMAIL_DOMAIN: 'kody.codes', + }, + }), + ).toEqual({ + appBaseUrl: 'https://kody-pr-1708.example.workers.dev', + fromEmail: 'kody@kody.codes', + }) + + expect( + resolveTransactionalEmailConfig({ + env: { APP_BASE_URL: 'https://app.example.com/path' }, + }), + ).toEqual({ + appBaseUrl: 'https://app.example.com', + fromEmail: 'kody@app.example.com', + }) + expect( resolveTransactionalEmailConfig({ env: { diff --git a/packages/worker/src/app/email/sender-config.ts b/packages/worker/src/app/email/sender-config.ts index 3baf7325cc..acb23c11b7 100644 --- a/packages/worker/src/app/email/sender-config.ts +++ b/packages/worker/src/app/email/sender-config.ts @@ -1,8 +1,11 @@ +import { getCanonicalAppBaseUrl } from '#worker/app-base-url.ts' +import { parseLegacyHosts } from '#worker/app-legacy-redirect.ts' import { getSystemEmailDomain } from '#worker/email/platform-address.ts' export type TransactionalEmailEnv = { APP_BASE_URL?: string | null SYSTEM_EMAIL_DOMAIN?: string | null + LEGACY_SYSTEM_EMAIL_DOMAINS?: string | null WRANGLER_IS_LOCAL_DEV?: string } @@ -13,6 +16,12 @@ export type TransactionalEmailConfig = { fromEmail: string } +const canonicalPublicOrigin = 'https://kody.codes' +const bakedInLegacyOutboundHosts: ReadonlyArray = [ + 'heykody.app', + 'heykody.dev', +] + function tryOrigin(value: string | URL | null | undefined) { if (value == null || value === '') return null try { @@ -22,38 +31,59 @@ function tryOrigin(value: string | URL | null | undefined) { } } +function isLegacyOutboundHost(hostname: string, env: TransactionalEmailEnv) { + const host = hostname.toLowerCase() + if (bakedInLegacyOutboundHosts.includes(host)) { + return true + } + return parseLegacyHosts(env.LEGACY_SYSTEM_EMAIL_DOMAINS).includes(host) +} + +function remapOutboundHost(hostname: string, env: TransactionalEmailEnv) { + if (!isLegacyOutboundHost(hostname, env)) return hostname + const systemDomain = getSystemEmailDomain(env) + if (systemDomain && !isLegacyOutboundHost(systemDomain, env)) { + return systemDomain + } + return new URL(canonicalPublicOrigin).hostname +} + /** - * Resolve the From address and link origin for signup, email-change, and - * password-reset mail. + * Resolve the From address and link origin for signup, email-change, + * password-reset, and entitlement-warning mail. * - * From always follows the sending domain (`SYSTEM_EMAIL_DOMAIN`, else the - * `APP_BASE_URL` hostname). Link hosts follow that same domain so a legacy - * `APP_BASE_URL` or dual-served host cannot pin `heykody.dev` into the - * message. Local `npm run dev` keeps clickable links on the request origin - * so signup still works against localhost. + * From follows `SYSTEM_EMAIL_DOMAIN` when set, otherwise the worker hostname + * (`APP_BASE_URL`, else the request host). Links follow that worker origin + * so preview mail points at the preview worker. A leftover `heykody.app` / + * `heykody.dev` origin — or any `LEGACY_SYSTEM_EMAIL_DOMAINS` host — remaps + * both From and links to `SYSTEM_EMAIL_DOMAIN` when that override is a + * current host, otherwise `kody.codes`. Local `npm run dev` keeps clickable + * links on the request origin so signup still works against localhost. */ export function resolveTransactionalEmailConfig(input: { env: TransactionalEmailEnv requestUrl?: string | URL | null }): TransactionalEmailConfig | null { - const systemDomain = getSystemEmailDomain(input.env) const requestOrigin = tryOrigin(input.requestUrl) const configuredOrigin = tryOrigin(input.env.APP_BASE_URL?.trim()) - const fallbackOrigin = configuredOrigin ?? requestOrigin - const fromHost = - systemDomain ?? (fallbackOrigin ? new URL(fallbackOrigin).hostname : null) - if (!fromHost) return null + const systemDomain = getSystemEmailDomain(input.env) + if (!configuredOrigin && !requestOrigin && !systemDomain) return null + + const workerOrigin = getCanonicalAppBaseUrl({ + env: input.env, + requestUrl: input.requestUrl, + }) + const workerHost = new URL(workerOrigin).hostname + const fromHost = remapOutboundHost(systemDomain ?? workerHost, input.env) const fromEmail = `kody@${fromHost}` if (input.env.WRANGLER_IS_LOCAL_DEV === 'true') { const localOrigin = requestOrigin ?? configuredOrigin if (localOrigin) return { appBaseUrl: localOrigin, fromEmail } } - if (fallbackOrigin && new URL(fallbackOrigin).hostname === fromHost) { - return { appBaseUrl: fallbackOrigin, fromEmail } - } - if (systemDomain) { - return { appBaseUrl: `https://${systemDomain}`, fromEmail } - } - return fallbackOrigin ? { appBaseUrl: fallbackOrigin, fromEmail } : null + + const linkHost = remapOutboundHost(workerHost, input.env) + const appBaseUrl = + linkHost === workerHost ? workerOrigin : `https://${linkHost}` + return { appBaseUrl, fromEmail } } diff --git a/packages/worker/src/app/user-entitlement-warning-emails.node.test.ts b/packages/worker/src/app/user-entitlement-warning-emails.node.test.ts index a9779a885c..6ca8deba4a 100644 --- a/packages/worker/src/app/user-entitlement-warning-emails.node.test.ts +++ b/packages/worker/src/app/user-entitlement-warning-emails.node.test.ts @@ -86,7 +86,7 @@ function createEnv(input: { }) { return { APP_DB: createDb(input.users), - APP_BASE_URL: 'https://heykody.dev/', + APP_BASE_URL: 'https://kody.codes/', CLOUDFLARE_ACCOUNT_ID: 'acct', CLOUDFLARE_API_TOKEN: 'token', BUNDLE_ARTIFACTS_KV: input.kv, @@ -144,17 +144,17 @@ test('user entitlement warnings send one 80% email and one 100% email per UTC da text: string } expect(approachingPayload.to).toBe('jelias@example.com') - expect(approachingPayload.from).toBe('kody@heykody.dev') + expect(approachingPayload.from).toBe('kody@kody.codes') expect(approachingPayload.subject).toContain('approaching') expect(approachingPayload.html).toContain( - 'https://heykody.dev/account/billing', + 'https://kody.codes/account/billing', ) - expect(approachingPayload.text).toContain('https://heykody.dev/account/usage') + expect(approachingPayload.text).toContain('https://kody.codes/account/usage') expect(approachingPayload.html).toContain('execute calls per day') expect(approachingPayload.html).toContain('saved packages') expect(approachingPayload.html).not.toContain('secrets') expect(approachingPayload.html).toContain( - 'https://heykody.dev/images/kody-lantern.png', + 'https://kody.codes/images/kody-lantern.png', ) const approachingKey = userEntitlementWarningKvKey({