Migrate outbound email delivery to Cloudflare Email - #137
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 15 minutes and 54 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR removes the Resend email service integration and replaces it with Cloudflare Email Service. The changes include removing the mock-resend server entirely, adding email message storage to the Cloudflare mock server via Durable Objects, updating the CLI mock lifecycle to manage Cloudflare configuration, and refactoring email-sending logic throughout the codebase. Changes
Sequence DiagramsequenceDiagram
participant Handler as Password Reset Handler
participant Validator as sendCloudflareEmail
participant API as Cloudflare Email API
participant Mock as Cloudflare Mock (Local Dev)
Handler->>Validator: sendCloudflareEmail(config, message)
Validator->>Validator: Validate message against outboundEmailSchema
alt Config Complete
Validator->>API: POST /client/v4/accounts/{id}/email-service/send
alt Success Response
API->>Validator: { success: true, result: { messageId } }
Validator->>Handler: { ok: true, id }
else Error Response
API->>Validator: { success: false, errors: [...] }
Validator->>Handler: { ok: false, error }
end
else Config Missing
Validator->>Handler: { ok: false, skipped: true }
end
Note over Mock: Local dev uses mock server<br/>with Durable Object storage
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
🔎 Preview deployed: https://kody-pr-137.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Binding send() return type likely mismatched with real API
- Updated binding typing and response handling so void responses are treated as success without throwing.
Preview (227dd2302c)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -116,17 +116,15 @@
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }}
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID }}
- RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
- RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL }}
+ CLOUDFLARE_EMAIL_FROM: ${{ secrets.CLOUDFLARE_EMAIL_FROM }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
CLOUDFLARE_API_BASE_URL: ${{ secrets.CLOUDFLARE_API_BASE_URL }}
CAPABILITY_REINDEX_SECRET: ${{ secrets.CAPABILITY_REINDEX_SECRET }}
run: >
node tools/ci/sync-worker-secrets.ts --env production --config
"$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET --set-from-env
- AI_GATEWAY_ID --set-from-env-optional RESEND_API_KEY
- --set-from-env-optional RESEND_FROM_EMAIL --set-from-env-optional
- SENTRY_DSN --set-from-env-optional CLOUDFLARE_API_BASE_URL
+ AI_GATEWAY_ID --set-from-env-optional CLOUDFLARE_EMAIL_FROM
+ --set-from-env-optional SENTRY_DSN --set-from-env-optional CLOUDFLARE_API_BASE_URL
--set-from-env-optional CLOUDFLARE_API_TOKEN --set-from-env-optional
CAPABILITY_REINDEX_SECRET
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -199,6 +199,9 @@
;;
esac
echo "${secret_env_key}=$MOCK_API_TOKEN" >> "$OVERRIDES_FILE"
+ if [ "$service_key" = "CLOUDFLARE" ]; then
+ echo "CLOUDFLARE_ACCOUNT_ID=cf_account_mock_123" >> "$OVERRIDES_FILE"
+ fi
mock_summary_line="- ${service}: ${mock_url} (\`${mock_worker_name}\`)"
mock_comment_summary_line="- ${service}: [${mock_url}/__mocks](${mock_url}/__mocks?token=${MOCK_API_TOKEN}) (\`${mock_worker_name}\`)"
diff --git a/cli.ts b/cli.ts
--- a/cli.ts
+++ b/cli.ts
@@ -68,7 +68,6 @@
let devChildren: Array<ChildProcess> = []
let workerOrigin = ''
let homeConnectorOrigin = ''
-let mockResendProcess: ChildProcess | null = null
let mockAiProcess: ChildProcess | null = null
let mockCloudflareProcess: ChildProcess | null = null
let mockEnvOverrides: Record<string, string> = {}
@@ -91,7 +90,7 @@
shutdown = setupShutdown(
() => devChildren,
() =>
- [mockResendProcess, mockAiProcess, mockCloudflareProcess].filter(
+ [mockAiProcess, mockCloudflareProcess].filter(
Boolean,
) as Array<ChildProcess>,
)
@@ -472,6 +471,7 @@
})
mockEnv.CLOUDFLARE_API_BASE_URL = baseUrl
mockEnv.CLOUDFLARE_API_TOKEN = apiToken
+ mockEnv.CLOUDFLARE_ACCOUNT_ID = 'cf_account_mock_123'
const didStart = await waitForMockReady(baseUrl, child)
if (!didStart) {
console.warn(
@@ -491,125 +491,64 @@
async function ensureMockServers() {
const previousMockEnvOverrides = { ...mockEnvOverrides }
const desiredAiMode = resolveAiMode()
- const canReuseResendMock = isChildRunning(mockResendProcess)
const canReuseAiMock = isChildRunning(mockAiProcess)
const hasMatchingCachedMode = mockEnvOverrides.AI_MODE === desiredAiMode
- const canReuseCachedResendEnv =
- canReuseResendMock &&
- hasEnvValue(mockEnvOverrides.RESEND_API_BASE_URL) &&
- hasEnvValue(mockEnvOverrides.RESEND_API_KEY)
const canReuseCachedAiEnv =
canReuseAiMock &&
hasEnvValue(previousMockEnvOverrides.AI_MOCK_BASE_URL) &&
hasEnvValue(previousMockEnvOverrides.AI_MOCK_API_KEY)
+ const canReuseCachedCloudflareEnv =
+ isChildRunning(mockCloudflareProcess) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_API_BASE_URL) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_API_TOKEN) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_ACCOUNT_ID)
if (
- canReuseCachedResendEnv &&
+ canReuseCachedCloudflareEnv &&
hasMatchingCachedMode &&
(desiredAiMode === 'remote' || canReuseCachedAiEnv)
) {
- const resendForAnchor = new URL(
- mockEnvOverrides.RESEND_API_BASE_URL ??
- `http://127.0.0.1:${defaultMockPort}`,
+ const cloudflareForAnchor = new URL(
+ mockEnvOverrides.CLOUDFLARE_API_BASE_URL ??
+ `http://127.0.0.1:${defaultMockPort + 240}`,
)
const anchorFromReuse = Number.parseInt(
- resendForAnchor.port || String(defaultMockPort),
+ cloudflareForAnchor.port || String(defaultMockPort + 240),
10,
)
await attachOptionalMocksInParallel(mockEnvOverrides, anchorFromReuse)
return mockEnvOverrides
}
- if (!canReuseResendMock && mockAiProcess && !mockAiProcess.killed) {
+ if (mockCloudflareProcess && !mockCloudflareProcess.killed) {
+ await stopChild(mockCloudflareProcess)
+ mockCloudflareProcess = null
+ }
+ if (mockAiProcess && !mockAiProcess.killed && !canReuseCachedAiEnv) {
await stopChild(mockAiProcess)
mockAiProcess = null
}
- let mockPort: number
- if (canReuseCachedResendEnv) {
- const resendBaseUrl = new URL(mockEnvOverrides.RESEND_API_BASE_URL ?? '')
- const parsedResendPort = Number.parseInt(
- resendBaseUrl.port || String(defaultMockPort),
- 10,
- )
- mockPort = Number.isNaN(parsedResendPort)
- ? defaultMockPort
- : parsedResendPort
- mockEnvOverrides = {
- ...mockEnvOverrides,
- AI_MODE: desiredAiMode,
- }
- } else {
- const desiredPort = Number.parseInt(
- process.env.MOCK_API_PORT ?? String(defaultMockPort),
- 10,
- )
- const portRange = Array.from(
- { length: 10 },
- (_, index) => desiredPort + index,
- )
- mockPort = await getPort({ port: portRange })
- if (mockCloudflareProcess && !mockCloudflareProcess.killed) {
- await stopChild(mockCloudflareProcess)
- mockCloudflareProcess = null
- }
- const baseUrl = `http://127.0.0.1:${mockPort}`
- const apiToken = `mock-resend-${randomUUID()}`
- const child = runNpmScript(
- 'dev:mock-resend',
- [
- '--port',
- String(mockPort),
- '--ip',
- '127.0.0.1',
- '--var',
- `MOCK_API_TOKEN:${apiToken}`,
- ],
- {},
- {
- label: 'dev:mock-resend',
- mode: 'buffer-on-error',
- },
- )
- mockResendProcess = child
- child.once('exit', () => {
- if (mockResendProcess === child) {
- mockResendProcess = null
- }
- })
- mockEnvOverrides = {
- RESEND_API_BASE_URL: baseUrl,
- RESEND_API_KEY: apiToken,
- AI_MODE: desiredAiMode,
- }
- if (!hasEnvValue(process.env.RESEND_FROM_EMAIL)) {
- mockEnvOverrides.RESEND_FROM_EMAIL = 'reset@kody.dev'
- }
+ const desiredPort = Number.parseInt(
+ process.env.MOCK_API_PORT ?? String(defaultMockPort),
+ 10,
+ )
+ const portRange = Array.from(
+ { length: 10 },
+ (_, index) => desiredPort + index,
+ )
+ const mockPort = await getPort({ port: portRange })
+ mockEnvOverrides = {
+ AI_MODE: desiredAiMode,
}
-
- const pendingMockStarts: Array<Promise<void>> = []
- const resendBaseUrl = mockEnvOverrides.RESEND_API_BASE_URL
- if (resendBaseUrl && mockResendProcess && !canReuseCachedResendEnv) {
- pendingMockStarts.push(
- (async () => {
- const didStart = await waitForMockReady(
- resendBaseUrl,
- mockResendProcess,
- )
- if (!didStart) {
- console.warn(
- `Mock API worker did not become ready within ${mockReadyTimeoutMs}ms.`,
- )
- }
- console.log(dim(`Mock API worker running at ${resendBaseUrl}`))
- console.log(dim(`Resend mock base URL ${resendBaseUrl}`))
- })(),
- )
+ if (!hasEnvValue(process.env.CLOUDFLARE_EMAIL_FROM)) {
+ mockEnvOverrides.CLOUDFLARE_EMAIL_FROM = 'reset@kody.dev'
}
const optionalMocksReady = attachOptionalMocksInParallel(
mockEnvOverrides,
mockPort,
)
+ const pendingMockStarts: Array<Promise<void>> = []
if (desiredAiMode === 'mock') {
if (canReuseCachedAiEnv) {
@@ -668,7 +607,7 @@
mockEnvOverrides.AI_MOCK_API_KEY = ''
}
- await Promise.all([...pendingMockStarts, optionalMocksReady])
+ await Promise.all([optionalMocksReady, ...pendingMockStarts])
return mockEnvOverrides
}
diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md
--- a/docs/contributing/setup-manifest.md
+++ b/docs/contributing/setup-manifest.md
@@ -66,18 +66,19 @@
auth metadata, generated UI resources, and email links)
- `APP_COMMIT_SHA` (optional; set automatically by deploy workflows for
version-aware `/health` checks)
-- `RESEND_API_BASE_URL` (optional, defaults to `https://api.resend.com`)
-- `RESEND_API_KEY` (optional, required to send via Resend)
-- `RESEND_FROM_EMAIL` (optional, required to send via Resend)
+- `CLOUDFLARE_EMAIL_FROM` (optional; sender address for outbound email)
- `AI_GATEWAY_ID` (required when `AI_MODE=remote`; deploy workflows sync a
gateway ID from GitHub Actions secrets so remote inference goes through
Cloudflare AI Gateway)
- `CLOUDFLARE_ACCOUNT_ID` (required for local development when `AI_MODE=remote`
so Wrangler can authenticate Workers AI requests against the correct account;
also required when using the `page_to_markdown` capability's Cloudflare
- Browser Rendering fallback against the live API)
+ Browser Rendering fallback against the live API and for the Cloudflare Email
+ Service REST API fallback used by local mocks and preview deploys)
- `CLOUDFLARE_API_TOKEN` (required for local development when `AI_MODE=remote`
- so Wrangler can authenticate Workers AI requests)
+ so Wrangler can authenticate Workers AI requests; also reused by the
+ Cloudflare Email Service REST API fallback when local/preview email is routed
+ through the Cloudflare mock or API)
- `SENTRY_DSN` (optional Cloudflare Worker secret; enables error reporting and
tracing for the Worker and Durable Objects)
- `SENTRY_ENVIRONMENT` (set per deploy via `packages/worker/wrangler.jsonc`
@@ -113,9 +114,7 @@
- `AI_GATEWAY_ID` (required for production deploys that use remote AI inference)
- `AI_GATEWAY_ID_PREVIEW` (required for preview deploys that use remote AI
inference)
-- `RESEND_API_KEY` (optional, required to send via Resend in non-mock
- environments)
-- `RESEND_FROM_EMAIL` (optional, required to send via Resend)
+- `CLOUDFLARE_EMAIL_FROM` (optional, required to send app email)
- `SENTRY_DSN` (optional; create a JavaScript/Cloudflare project in Sentry and
paste the DSN; syncs to the Worker as a secret when set in GitHub Actions)
- `CAPABILITY_REINDEX_SECRET` (optional; triggers post-deploy Vectorize reindex
@@ -160,11 +159,9 @@
ID.
- Store that value as the preview GitHub Actions secret so preview deploys
sync a different worker secret than production.
-- `RESEND_API_KEY` (optional)
- - Create in Resend Dashboard (API keys), then store in GitHub Actions secrets.
-- `RESEND_FROM_EMAIL` (optional)
- - Use your verified sender/from address in Resend (for example
- `noreply@example.com`), then store it as a secret.
+- `CLOUDFLARE_EMAIL_FROM` (optional)
+ - Use a sender address on a domain onboarded to Cloudflare Email Service (for
+ example `noreply@example.com`), then store it as a GitHub Actions secret.
- `SENTRY_DSN` (optional)
- In Sentry: create a project, copy the DSN, and add it as the repository
secret `SENTRY_DSN`. Production and preview deploy workflows sync it with
diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md
--- a/docs/contributing/setup.md
+++ b/docs/contributing/setup.md
@@ -33,16 +33,18 @@
- Copy `packages/worker/.env.example` to `packages/worker/.env` before starting
any work, then update secrets as needed.
- `npm run dev` (starts mock API servers automatically, the main worker, and the
- local home connector; it sets `RESEND_API_BASE_URL`, `AI_MODE=mock`,
- `AI_MOCK_BASE_URL`, and (unless `SKIP_CLOUDFLARE_MOCK=1` or `AI_MODE=remote`)
- `CLOUDFLARE_API_BASE_URL` + `CLOUDFLARE_API_TOKEN` to the local Cloudflare API
- mock Worker for `page_to_markdown` and the internal Cloudflare API client. The
+ local home connector; it sets `AI_MODE=mock`, `AI_MOCK_BASE_URL`, and
+ `CLOUDFLARE_API_BASE_URL` + `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`
+ to the local Cloudflare API mock Worker for `page_to_markdown`, the internal
+ Cloudflare API client, and local email sending. Unless you already set
+ `CLOUDFLARE_EMAIL_FROM`, the launcher also defaults it to `reset@kody.dev`.
+ Set `SKIP_CLOUDFLARE_MOCK=1` to skip the local Cloudflare mock entirely. The
home connector receives the resolved worker origin via `WORKER_BASE_URL`. When
`HOME_CONNECTOR_SHARED_SECRET` is unset, the launcher generates one and passes
it to both the worker and the connector so the outbound registration handshake
succeeds in local development. The main worker and home connector stream logs
- live; the client bundle and background mock workers now buffer their logs and
- only print them if that child process exits with an error.)
+ live; the client bundle and background mock workers buffer logs and only print
+ them if that child process exits with an error.)
- The home automation connector now lives in `packages/home-connector`.
- `npm run dev:home-connector` starts the local connector app on Node 24 with
`node --watch`, so connector code changes automatically restart the local
diff --git a/package-lock.json b/package-lock.json
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,7 +50,7 @@
"set-cookie-parser": "^3.0.1",
"typescript": "^5.9.3",
"vitest": "^4.1.1",
- "wrangler": "^4.72.0"
+ "wrangler": "^4.81.1"
},
"engines": {
"node": "24.x"
@@ -1261,6 +1261,41 @@
"node": ">=18"
}
},
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": {
+ "version": "4.77.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.77.0.tgz",
+ "integrity": "sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.4.2",
+ "@cloudflare/unenv-preset": "2.16.0",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.27.3",
+ "miniflare": "4.20260317.2",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260317.1"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=20.3.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20260317.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@cloudflare/vitest-pool-workers/node_modules/zod": {
"version": "3.25.76",
"dev": true,
@@ -1357,7 +1392,9 @@
"license": "MIT"
},
"node_modules/@cloudflare/workers-types": {
- "version": "4.20260317.1",
+ "version": "4.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260409.1.tgz",
+ "integrity": "sha512-0rGuppPeip6dqlI6013wC8tE+kbRK+tcaDfqCxKf9sEHDNfSWWUuKgIEDpt6IHHP2O0iYBQpngk5Siv4CL/HGQ==",
"license": "MIT OR Apache-2.0",
"peer": true
},
@@ -3055,10 +3092,6 @@
"resolved": "packages/mock-servers/cloudflare",
"link": true
},
- "node_modules/@kody/mock-resend": {
- "resolved": "packages/mock-servers/resend",
- "link": true
- },
"node_modules/@kody/worker": {
"resolved": "packages/worker",
"link": true
@@ -4211,9 +4244,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4231,9 +4261,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4251,9 +4278,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4271,9 +4295,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4291,9 +4312,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4311,9 +4329,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4331,9 +4346,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4351,9 +4363,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5966,9 +5975,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5983,9 +5989,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5999,9 +6002,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6016,9 +6016,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6033,9 +6030,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6050,9 +6044,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6067,9 +6058,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6084,9 +6072,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6101,9 +6086,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6118,9 +6100,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6135,9 +6114,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6151,9 +6127,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6168,9 +6141,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -21607,7 +21577,9 @@
}
},
"node_modules/wrangler": {
- "version": "4.77.0",
+ "version": "4.81.1",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.81.1.tgz",
+ "integrity": "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -21615,10 +21587,10 @@
"@cloudflare/unenv-preset": "2.16.0",
"blake3-wasm": "2.1.5",
"esbuild": "0.27.3",
- "miniflare": "4.20260317.2",
+ "miniflare": "4.20260409.0",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
- "workerd": "1.20260317.1"
+ "workerd": "1.20260409.1"
},
"bin": {
"wrangler": "bin/wrangler.js",
@@ -21631,7 +21603,7 @@
"fsevents": "~2.3.2"
},
"peerDependencies": {
- "@cloudflare/workers-types": "^4.20260317.1"
+ "@cloudflare/workers-types": "^4.20260409.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
@@ -21639,6 +21611,91 @@
}
}
},
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260409.1.tgz",
+ "integrity": "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260409.1.tgz",
+ "integrity": "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260409.1.tgz",
+ "integrity": "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260409.1.tgz",
+ "integrity": "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260409.1.tgz",
+ "integrity": "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/wrangler/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -22119,6 +22176,58 @@
"node": ">=18"
}
},
+ "node_modules/wrangler/node_modules/miniflare": {
+ "version": "4.20260409.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260409.0.tgz",
+ "integrity": "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "^0.34.5",
+ "undici": "7.24.4",
+ "workerd": "1.20260409.1",
+ "ws": "8.18.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/wrangler/node_modules/undici": {
+ "version": "7.24.4",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
+ "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/wrangler/node_modules/workerd": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260409.1.tgz",
+ "integrity": "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260409.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260409.1",
+ "@cloudflare/workerd-linux-64": "1.20260409.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260409.1",
+ "@cloudflare/workerd-windows-64": "1.20260409.1"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"license": "MIT",
@@ -22350,13 +22459,9 @@
}
},
"packages/mock-servers/cloudflare": {
- "name": "@kody/mock-cloudflare"
- },
- "packages/mock-servers/resend": {
- "name": "@kody/mock-resend",
+ "name": "@kody/mock-cloudflare",
"dependencies": {
- "@kody-internal/shared": "file:../../shared",
- "@kody/worker": "file:../../worker"
+ "@kody-internal/shared": "file:../../shared"
}
... diff truncated: showing 800 of 5957 linesYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/worker/src/app/handlers/password-reset.ts (1)
138-146:⚠️ Potential issue | 🟠 MajorDon't write reset links to logs when email config is missing.
This path passes
email.htmlintologMissingEmailConfig, and that HTML contains the live reset token. Any environment missingCLOUDFLARE_EMAIL_FROMwill leak usable password-reset URLs to logs.🔒 Proposed fix
function logMissingEmailConfig(payload: { to: string from: string subject: string - html: string }) { console.warn( 'cloudflare-email-from-missing', JSON.stringify({ to: payload.to, from: payload.from, subject: payload.subject, - body: payload.html, }), ) } ... logMissingEmailConfig({ to: normalizedEmail, from: fromEmail, subject: email.subject, - html: email.html, })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/handlers/password-reset.ts` around lines 138 - 146, The current branch logs full email HTML (email.html) including the live reset token when CLOUDFLARE_EMAIL_FROM is missing; update the call to logMissingEmailConfig so it does not include email.html or any content that can contain tokens—pass only non-sensitive metadata (e.g., to: normalizedEmail, from: fromEmail, subject: email.subject) or a sanitized flag indicating the body was suppressed, and ensure functions like logMissingEmailConfig and any callers no longer accept or write the raw email body to logs.cli.ts (1)
431-434:⚠️ Potential issue | 🟠 MajorRemote AI mode currently bypasses local email capture.
This early return skips the Cloudflare mock whenever
AI_MODE=remote, but Lines 543-545 still inject a sender address. In local dev the email path uses the REST client, so the worker falls back to the realCLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_API_TOKENfrom.envand can send password-reset mail to the live Cloudflare Email API instead of the mock inbox.Also applies to: 543-545
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cli.ts` around lines 431 - 434, The early return using resolveAiMode() === 'remote' (assigned to shouldPreserveRealToken) prevents the Cloudflare email mock from being registered but later code still injects a sender address, causing the real CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_API_TOKEN to be used; change the logic so that resolveAiMode()/shouldPreserveRealToken only prevents overwriting auth tokens but does not return early—allow the mock registration and email client setup to run, and additionally guard the sender injection code so it uses mock sender values or is skipped when in remote mode; update the code paths around resolveAiMode(), shouldPreserveRealToken, and the sender-injection block to reflect this behavior.
🧹 Nitpick comments (2)
packages/worker/src/app/router.ts (1)
55-107: Instantiate shared handlers once, then reuse inrouter.map.From Line 55 through Line 107, the same handler factories are called repeatedly with the same env. Hoisting these into constants reduces duplication and prevents route wiring drift.
♻️ Proposed refactor
+ const accountSecretsHandler = createAccountSecretsHandler(appEnv as unknown as Env) + const accountSecretsApiHandler = createAccountSecretsApiHandler(appEnv as unknown as Env) + const connectSecretHandler = createConnectSecretHandler(appEnv as unknown as Env) + const connectSecretApiHandler = createConnectSecretApiHandler(appEnv as unknown as Env) + const connectOauthHandler = createConnectOauthHandler(appEnv as unknown as Env) router.map( routes.accountSecrets, - createAccountSecretsHandler(appEnv as unknown as Env), + accountSecretsHandler, ) router.map( routes.accountSecretNew, - createAccountSecretsHandler(appEnv as unknown as Env), + accountSecretsHandler, ) router.map( routes.accountSecretsApprove, - createAccountSecretsHandler(appEnv as unknown as Env), + accountSecretsHandler, ) // ...same reuse for other account secret routes... router.map( routes.accountSecretsApi, - createAccountSecretsApiHandler(appEnv as unknown as Env), + accountSecretsApiHandler, ) router.map( routes.accountSecretsApiPost, - createAccountSecretsApiHandler(appEnv as unknown as Env), + accountSecretsApiHandler, ) router.map( routes.connectSecret, - createConnectSecretHandler(appEnv as unknown as Env), + connectSecretHandler, ) router.map( routes.connectSecretApi, - createConnectSecretApiHandler(appEnv as unknown as Env), + connectSecretApiHandler, ) router.map( routes.connectSecretApiPost, - createConnectSecretApiHandler(appEnv as unknown as Env), + connectSecretApiHandler, ) router.map( routes.connectOauth, - createConnectOauthHandler(appEnv as unknown as Env), + connectOauthHandler, )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/router.ts` around lines 55 - 107, The router currently calls the same factory functions repeatedly with the same env (e.g., createAccountSecretsHandler, createAccountSecretsApiHandler, createConnectSecretHandler, createConnectSecretApiHandler, createConnectOauthHandler, createSavedUiPageHandler) which duplicates work and risks drift; fix by hoisting each handler instance into a const (and perform the appEnv as Env cast once) and then pass those constants into router.map for the relevant routes so each route reuses the same handler instance instead of recreating it.packages/worker/src/app/handlers/chat-threads.ts (1)
24-27: Remove unsafeas unknown as Envcasts at the auth boundary.Lines 24-27, 76-79, and 132-135 use an unsafe type assertion that suppresses structural type checking. If
AppEnvandEnvcontracts diverge, the mismatch won't be caught at compile time. All other handlers (chat-agent.ts, account-secrets.ts, connect-secret.ts, etc.) successfully callreadAuthenticatedAppUserby passingenvdirectly without casting.♻️ Recommended approach
Either:
Option A: Pass raw
envdirectly (matches other call sites):- const user = await readAuthenticatedAppUser(request, appEnv as unknown as Env) + const user = await readAuthenticatedAppUser(request, env)Option B: Refactor
readAuthenticatedAppUserto acceptAppEnv(orPick<AppEnv, 'COOKIE_SECRET'>) and update all call sites accordingly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/handlers/chat-threads.ts` around lines 24 - 27, The code uses unsafe casts "as unknown as Env" when calling readAuthenticatedAppUser; remove those casts and either (A) pass the raw env object directly to readAuthenticatedAppUser (matching other handlers) by replacing appEnv as unknown as Env with env, or (B) if env is an AppEnv type, update the readAuthenticatedAppUser signature to accept AppEnv (or a narrower Pick<AppEnv,'COOKIE_SECRET'>) and update all call sites accordingly (adjust imports/types for readAuthenticatedAppUser and its callers).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/worker/src/app/email/cloudflare-email.node.test.ts`:
- Around line 33-67: startCloudflareMock currently spawns the wrangler process
(via spawnProcess) and then awaits waitForMock(origin) but if waitForMock throws
the function rejects without cleaning up, orphaning the child; wrap the
waitForMock call in a try/catch (or try/finally) so that on any error you await
stopProcess(proc) before rethrowing the error, and still return the async
disposer (Symbol.asyncDispose) on success; reference startCloudflareMock,
spawnProcess, waitForMock, stopProcess, and Symbol.asyncDispose when applying
the change.
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 59-69: The logSkippedEmail function currently includes the full
email HTML in logs (body: message.html) which can expose PII; update
logSkippedEmail to remove the body from the logged payload and only log metadata
(to, from, subject) or, if a preview is needed, include a safely truncated
preview (e.g., first N characters) and an explicit "[truncated]" marker; modify
the JSON.stringify payload inside logSkippedEmail accordingly so it no longer
includes message.html but uses only message.to, message.from, message.subject or
a truncated preview.
In `@packages/worker/src/app/handlers/password-reset.ts`:
- Around line 149-164: The call to sendCloudflareEmail is not checking its
return value, so both successes and failures are logged as success; modify the
password reset flow to capture the result from sendCloudflareEmail (call site
uses normalizedEmail, fromEmail and email.subject/html/text) into a const (e.g.,
sendResult), then check sendResult.ok before calling logAuditEvent with result:
'success' — if sendResult.ok is false, call logAuditEvent with result: 'failure'
(including sendResult.error) and handle the failure path (return an error
response or throw) instead of proceeding as if the email was delivered.
---
Outside diff comments:
In `@cli.ts`:
- Around line 431-434: The early return using resolveAiMode() === 'remote'
(assigned to shouldPreserveRealToken) prevents the Cloudflare email mock from
being registered but later code still injects a sender address, causing the real
CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_API_TOKEN to be used; change the logic so that
resolveAiMode()/shouldPreserveRealToken only prevents overwriting auth tokens
but does not return early—allow the mock registration and email client setup to
run, and additionally guard the sender injection code so it uses mock sender
values or is skipped when in remote mode; update the code paths around
resolveAiMode(), shouldPreserveRealToken, and the sender-injection block to
reflect this behavior.
In `@packages/worker/src/app/handlers/password-reset.ts`:
- Around line 138-146: The current branch logs full email HTML (email.html)
including the live reset token when CLOUDFLARE_EMAIL_FROM is missing; update the
call to logMissingEmailConfig so it does not include email.html or any content
that can contain tokens—pass only non-sensitive metadata (e.g., to:
normalizedEmail, from: fromEmail, subject: email.subject) or a sanitized flag
indicating the body was suppressed, and ensure functions like
logMissingEmailConfig and any callers no longer accept or write the raw email
body to logs.
---
Nitpick comments:
In `@packages/worker/src/app/handlers/chat-threads.ts`:
- Around line 24-27: The code uses unsafe casts "as unknown as Env" when calling
readAuthenticatedAppUser; remove those casts and either (A) pass the raw env
object directly to readAuthenticatedAppUser (matching other handlers) by
replacing appEnv as unknown as Env with env, or (B) if env is an AppEnv type,
update the readAuthenticatedAppUser signature to accept AppEnv (or a narrower
Pick<AppEnv,'COOKIE_SECRET'>) and update all call sites accordingly (adjust
imports/types for readAuthenticatedAppUser and its callers).
In `@packages/worker/src/app/router.ts`:
- Around line 55-107: The router currently calls the same factory functions
repeatedly with the same env (e.g., createAccountSecretsHandler,
createAccountSecretsApiHandler, createConnectSecretHandler,
createConnectSecretApiHandler, createConnectOauthHandler,
createSavedUiPageHandler) which duplicates work and risks drift; fix by hoisting
each handler instance into a const (and perform the appEnv as Env cast once) and
then pass those constants into router.map for the relevant routes so each route
reuses the same handler instance instead of recreating it.
🪄 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
Run ID: 9c9ede0f-a9d4-45ec-a85c-3f30c12d8987
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (27)
.github/workflows/deploy.yml.github/workflows/preview.ymlcli.tsdocs/contributing/setup-manifest.mddocs/contributing/setup.mdpackage.jsonpackages/mock-servers/cloudflare/package.jsonpackages/mock-servers/cloudflare/src/mock-email-messages-do.tspackages/mock-servers/cloudflare/src/worker.tspackages/mock-servers/cloudflare/wrangler.jsoncpackages/mock-servers/resend/package.jsonpackages/mock-servers/resend/project.jsonpackages/mock-servers/resend/src/mock-messages-do.tspackages/mock-servers/resend/src/worker.tspackages/mock-servers/resend/wrangler.jsoncpackages/shared/src/outbound-email.tspackages/worker/.env.examplepackages/worker/src/app/email/cloudflare-email.node.test.tspackages/worker/src/app/email/cloudflare-email.tspackages/worker/src/app/email/resend.tspackages/worker/src/app/handlers/chat-threads.tspackages/worker/src/app/handlers/password-reset.tspackages/worker/src/app/router.tspackages/worker/src/env-schema.tspackages/worker/worker-configuration.d.tspackages/worker/wrangler.jsonctools/ci/sync-worker-secrets.node.test.ts
💤 Files with no reviewable changes (6)
- packages/mock-servers/resend/project.json
- packages/mock-servers/resend/package.json
- packages/mock-servers/resend/wrangler.jsonc
- packages/worker/src/app/email/resend.ts
- packages/mock-servers/resend/src/mock-messages-do.ts
- packages/mock-servers/resend/src/worker.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
packages/worker/src/app/email/cloudflare-email.ts (1)
61-69:⚠️ Potential issue | 🟠 MajorRemove HTML body from skipped-email logs to avoid PII leakage.
Line 68 logs
message.html, which can include sensitive user content (including reset-link context). Keep only metadata fields in this warning log.🛡️ Proposed fix
function logSkippedEmail(reason: string, message: OutboundEmail) { console.warn( reason, JSON.stringify({ to: message.to, from: message.from, subject: message.subject, - body: message.html, }), ) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/email/cloudflare-email.ts` around lines 61 - 69, The logSkippedEmail function currently includes message.html (and potentially other message body fields) in the JSON.stringify payload which may leak PII; update logSkippedEmail to remove message.html (and any message.text/body fields) from the logged object and only include metadata such as message.to, message.from, and message.subject so the warning no longer contains user content.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 87-98: The function that posts email to Cloudflare (uses endpoint,
fetch with Authorization using config.apiToken, and variables response and
payload) currently lets fetch exceptions bubble and treats a null payload (from
JSON parse failure) as success; wrap the fetch call in a try/catch so any thrown
error returns a failure result ({ ok: false }), and handle JSON parsing errors
explicitly (catch the .json() rejection and treat payload === null as a
failure). Update the success condition to require both response.ok and
payload?.success === true before returning ok:true so transport or parse
failures always return ok:false.
---
Duplicate comments:
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 61-69: The logSkippedEmail function currently includes
message.html (and potentially other message body fields) in the JSON.stringify
payload which may leak PII; update logSkippedEmail to remove message.html (and
any message.text/body fields) from the logged object and only include metadata
such as message.to, message.from, and message.subject so the warning no longer
contains user content.
🪄 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
Run ID: e356d6e2-3d50-4a6f-92f4-e566008425d5
📒 Files selected for processing (1)
packages/worker/src/app/email/cloudflare-email.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 61-69: The warning logs currently emit raw email identifiers
(message.to, message.from) from logSkippedEmail (and the other console warnings
around the same block) — update those log statements to redact or hash email
addresses before logging: e.g., replace the local-part with asterisks or compute
a short irreversible hash of message.to and message.from and include only that
masked value in the JSON payload. Locate logSkippedEmail and any other
console.warn/console.error that serializes { to: message.to, from: message.from,
subject: ... } and change them to use the masked/hashed values (preserve subject
as-is), ensuring no raw email addresses are written to logs.
🪄 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
Run ID: 5da55761-ab3a-4516-a22d-c75b869a234b
📒 Files selected for processing (2)
packages/worker/src/app/email/cloudflare-email.node.test.tspackages/worker/src/app/email/cloudflare-email.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/worker/src/app/email/cloudflare-email.node.test.ts
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing API base URL default silently breaks production email
- Added a default Cloudflare API base URL in the password reset handler so email sends no longer skip when the optional env var is unset.
Preview (49b660be0c)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -116,17 +116,15 @@
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }}
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID }}
- RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
- RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL }}
+ CLOUDFLARE_EMAIL_FROM: ${{ secrets.CLOUDFLARE_EMAIL_FROM }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
CLOUDFLARE_API_BASE_URL: ${{ secrets.CLOUDFLARE_API_BASE_URL }}
CAPABILITY_REINDEX_SECRET: ${{ secrets.CAPABILITY_REINDEX_SECRET }}
run: >
node tools/ci/sync-worker-secrets.ts --env production --config
"$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET --set-from-env
- AI_GATEWAY_ID --set-from-env-optional RESEND_API_KEY
- --set-from-env-optional RESEND_FROM_EMAIL --set-from-env-optional
- SENTRY_DSN --set-from-env-optional CLOUDFLARE_API_BASE_URL
+ AI_GATEWAY_ID --set-from-env-optional CLOUDFLARE_EMAIL_FROM
+ --set-from-env-optional SENTRY_DSN --set-from-env-optional CLOUDFLARE_API_BASE_URL
--set-from-env-optional CLOUDFLARE_API_TOKEN --set-from-env-optional
CAPABILITY_REINDEX_SECRET
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -199,6 +199,9 @@
;;
esac
echo "${secret_env_key}=$MOCK_API_TOKEN" >> "$OVERRIDES_FILE"
+ if [ "$service_key" = "CLOUDFLARE" ]; then
+ echo "CLOUDFLARE_ACCOUNT_ID=cf_account_mock_123" >> "$OVERRIDES_FILE"
+ fi
mock_summary_line="- ${service}: ${mock_url} (\`${mock_worker_name}\`)"
mock_comment_summary_line="- ${service}: [${mock_url}/__mocks](${mock_url}/__mocks?token=${MOCK_API_TOKEN}) (\`${mock_worker_name}\`)"
diff --git a/cli.ts b/cli.ts
--- a/cli.ts
+++ b/cli.ts
@@ -68,7 +68,6 @@
let devChildren: Array<ChildProcess> = []
let workerOrigin = ''
let homeConnectorOrigin = ''
-let mockResendProcess: ChildProcess | null = null
let mockAiProcess: ChildProcess | null = null
let mockCloudflareProcess: ChildProcess | null = null
let mockEnvOverrides: Record<string, string> = {}
@@ -91,7 +90,7 @@
shutdown = setupShutdown(
() => devChildren,
() =>
- [mockResendProcess, mockAiProcess, mockCloudflareProcess].filter(
+ [mockAiProcess, mockCloudflareProcess].filter(
Boolean,
) as Array<ChildProcess>,
)
@@ -472,6 +471,7 @@
})
mockEnv.CLOUDFLARE_API_BASE_URL = baseUrl
mockEnv.CLOUDFLARE_API_TOKEN = apiToken
+ mockEnv.CLOUDFLARE_ACCOUNT_ID = 'cf_account_mock_123'
const didStart = await waitForMockReady(baseUrl, child)
if (!didStart) {
console.warn(
@@ -491,125 +491,64 @@
async function ensureMockServers() {
const previousMockEnvOverrides = { ...mockEnvOverrides }
const desiredAiMode = resolveAiMode()
- const canReuseResendMock = isChildRunning(mockResendProcess)
const canReuseAiMock = isChildRunning(mockAiProcess)
const hasMatchingCachedMode = mockEnvOverrides.AI_MODE === desiredAiMode
- const canReuseCachedResendEnv =
- canReuseResendMock &&
- hasEnvValue(mockEnvOverrides.RESEND_API_BASE_URL) &&
- hasEnvValue(mockEnvOverrides.RESEND_API_KEY)
const canReuseCachedAiEnv =
canReuseAiMock &&
hasEnvValue(previousMockEnvOverrides.AI_MOCK_BASE_URL) &&
hasEnvValue(previousMockEnvOverrides.AI_MOCK_API_KEY)
+ const canReuseCachedCloudflareEnv =
+ isChildRunning(mockCloudflareProcess) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_API_BASE_URL) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_API_TOKEN) &&
+ hasEnvValue(previousMockEnvOverrides.CLOUDFLARE_ACCOUNT_ID)
if (
- canReuseCachedResendEnv &&
+ canReuseCachedCloudflareEnv &&
hasMatchingCachedMode &&
(desiredAiMode === 'remote' || canReuseCachedAiEnv)
) {
- const resendForAnchor = new URL(
- mockEnvOverrides.RESEND_API_BASE_URL ??
- `http://127.0.0.1:${defaultMockPort}`,
+ const cloudflareForAnchor = new URL(
+ mockEnvOverrides.CLOUDFLARE_API_BASE_URL ??
+ `http://127.0.0.1:${defaultMockPort + 240}`,
)
const anchorFromReuse = Number.parseInt(
- resendForAnchor.port || String(defaultMockPort),
+ cloudflareForAnchor.port || String(defaultMockPort + 240),
10,
)
await attachOptionalMocksInParallel(mockEnvOverrides, anchorFromReuse)
return mockEnvOverrides
}
- if (!canReuseResendMock && mockAiProcess && !mockAiProcess.killed) {
+ if (mockCloudflareProcess && !mockCloudflareProcess.killed) {
+ await stopChild(mockCloudflareProcess)
+ mockCloudflareProcess = null
+ }
+ if (mockAiProcess && !mockAiProcess.killed && !canReuseCachedAiEnv) {
await stopChild(mockAiProcess)
mockAiProcess = null
}
- let mockPort: number
- if (canReuseCachedResendEnv) {
- const resendBaseUrl = new URL(mockEnvOverrides.RESEND_API_BASE_URL ?? '')
- const parsedResendPort = Number.parseInt(
- resendBaseUrl.port || String(defaultMockPort),
- 10,
- )
- mockPort = Number.isNaN(parsedResendPort)
- ? defaultMockPort
- : parsedResendPort
- mockEnvOverrides = {
- ...mockEnvOverrides,
- AI_MODE: desiredAiMode,
- }
- } else {
- const desiredPort = Number.parseInt(
- process.env.MOCK_API_PORT ?? String(defaultMockPort),
- 10,
- )
- const portRange = Array.from(
- { length: 10 },
- (_, index) => desiredPort + index,
- )
- mockPort = await getPort({ port: portRange })
- if (mockCloudflareProcess && !mockCloudflareProcess.killed) {
- await stopChild(mockCloudflareProcess)
- mockCloudflareProcess = null
- }
- const baseUrl = `http://127.0.0.1:${mockPort}`
- const apiToken = `mock-resend-${randomUUID()}`
- const child = runNpmScript(
- 'dev:mock-resend',
- [
- '--port',
- String(mockPort),
- '--ip',
- '127.0.0.1',
- '--var',
- `MOCK_API_TOKEN:${apiToken}`,
- ],
- {},
- {
- label: 'dev:mock-resend',
- mode: 'buffer-on-error',
- },
- )
- mockResendProcess = child
- child.once('exit', () => {
- if (mockResendProcess === child) {
- mockResendProcess = null
- }
- })
- mockEnvOverrides = {
- RESEND_API_BASE_URL: baseUrl,
- RESEND_API_KEY: apiToken,
- AI_MODE: desiredAiMode,
- }
- if (!hasEnvValue(process.env.RESEND_FROM_EMAIL)) {
- mockEnvOverrides.RESEND_FROM_EMAIL = 'reset@kody.dev'
- }
+ const desiredPort = Number.parseInt(
+ process.env.MOCK_API_PORT ?? String(defaultMockPort),
+ 10,
+ )
+ const portRange = Array.from(
+ { length: 10 },
+ (_, index) => desiredPort + index,
+ )
+ const mockPort = await getPort({ port: portRange })
+ mockEnvOverrides = {
+ AI_MODE: desiredAiMode,
}
-
- const pendingMockStarts: Array<Promise<void>> = []
- const resendBaseUrl = mockEnvOverrides.RESEND_API_BASE_URL
- if (resendBaseUrl && mockResendProcess && !canReuseCachedResendEnv) {
- pendingMockStarts.push(
- (async () => {
- const didStart = await waitForMockReady(
- resendBaseUrl,
- mockResendProcess,
- )
- if (!didStart) {
- console.warn(
- `Mock API worker did not become ready within ${mockReadyTimeoutMs}ms.`,
- )
- }
- console.log(dim(`Mock API worker running at ${resendBaseUrl}`))
- console.log(dim(`Resend mock base URL ${resendBaseUrl}`))
- })(),
- )
+ if (!hasEnvValue(process.env.CLOUDFLARE_EMAIL_FROM)) {
+ mockEnvOverrides.CLOUDFLARE_EMAIL_FROM = 'reset@kody.dev'
}
const optionalMocksReady = attachOptionalMocksInParallel(
mockEnvOverrides,
mockPort,
)
+ const pendingMockStarts: Array<Promise<void>> = []
if (desiredAiMode === 'mock') {
if (canReuseCachedAiEnv) {
@@ -668,7 +607,7 @@
mockEnvOverrides.AI_MOCK_API_KEY = ''
}
- await Promise.all([...pendingMockStarts, optionalMocksReady])
+ await Promise.all([optionalMocksReady, ...pendingMockStarts])
return mockEnvOverrides
}
diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md
--- a/docs/contributing/setup-manifest.md
+++ b/docs/contributing/setup-manifest.md
@@ -66,18 +66,19 @@
auth metadata, generated UI resources, and email links)
- `APP_COMMIT_SHA` (optional; set automatically by deploy workflows for
version-aware `/health` checks)
-- `RESEND_API_BASE_URL` (optional, defaults to `https://api.resend.com`)
-- `RESEND_API_KEY` (optional, required to send via Resend)
-- `RESEND_FROM_EMAIL` (optional, required to send via Resend)
+- `CLOUDFLARE_EMAIL_FROM` (optional; sender address for outbound email)
- `AI_GATEWAY_ID` (required when `AI_MODE=remote`; deploy workflows sync a
gateway ID from GitHub Actions secrets so remote inference goes through
Cloudflare AI Gateway)
- `CLOUDFLARE_ACCOUNT_ID` (required for local development when `AI_MODE=remote`
so Wrangler can authenticate Workers AI requests against the correct account;
also required when using the `page_to_markdown` capability's Cloudflare
- Browser Rendering fallback against the live API)
+ Browser Rendering fallback against the live API and for the Cloudflare Email
+ Service REST API fallback used by local mocks and preview deploys)
- `CLOUDFLARE_API_TOKEN` (required for local development when `AI_MODE=remote`
- so Wrangler can authenticate Workers AI requests)
+ so Wrangler can authenticate Workers AI requests; also reused by the
+ Cloudflare Email Service REST API fallback when local/preview email is routed
+ through the Cloudflare mock or API)
- `SENTRY_DSN` (optional Cloudflare Worker secret; enables error reporting and
tracing for the Worker and Durable Objects)
- `SENTRY_ENVIRONMENT` (set per deploy via `packages/worker/wrangler.jsonc`
@@ -113,9 +114,7 @@
- `AI_GATEWAY_ID` (required for production deploys that use remote AI inference)
- `AI_GATEWAY_ID_PREVIEW` (required for preview deploys that use remote AI
inference)
-- `RESEND_API_KEY` (optional, required to send via Resend in non-mock
- environments)
-- `RESEND_FROM_EMAIL` (optional, required to send via Resend)
+- `CLOUDFLARE_EMAIL_FROM` (optional, required to send app email)
- `SENTRY_DSN` (optional; create a JavaScript/Cloudflare project in Sentry and
paste the DSN; syncs to the Worker as a secret when set in GitHub Actions)
- `CAPABILITY_REINDEX_SECRET` (optional; triggers post-deploy Vectorize reindex
@@ -160,11 +159,9 @@
ID.
- Store that value as the preview GitHub Actions secret so preview deploys
sync a different worker secret than production.
-- `RESEND_API_KEY` (optional)
- - Create in Resend Dashboard (API keys), then store in GitHub Actions secrets.
-- `RESEND_FROM_EMAIL` (optional)
- - Use your verified sender/from address in Resend (for example
- `noreply@example.com`), then store it as a secret.
+- `CLOUDFLARE_EMAIL_FROM` (optional)
+ - Use a sender address on a domain onboarded to Cloudflare Email Service (for
+ example `noreply@example.com`), then store it as a GitHub Actions secret.
- `SENTRY_DSN` (optional)
- In Sentry: create a project, copy the DSN, and add it as the repository
secret `SENTRY_DSN`. Production and preview deploy workflows sync it with
diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md
--- a/docs/contributing/setup.md
+++ b/docs/contributing/setup.md
@@ -33,16 +33,18 @@
- Copy `packages/worker/.env.example` to `packages/worker/.env` before starting
any work, then update secrets as needed.
- `npm run dev` (starts mock API servers automatically, the main worker, and the
- local home connector; it sets `RESEND_API_BASE_URL`, `AI_MODE=mock`,
- `AI_MOCK_BASE_URL`, and (unless `SKIP_CLOUDFLARE_MOCK=1` or `AI_MODE=remote`)
- `CLOUDFLARE_API_BASE_URL` + `CLOUDFLARE_API_TOKEN` to the local Cloudflare API
- mock Worker for `page_to_markdown` and the internal Cloudflare API client. The
+ local home connector; it sets `AI_MODE=mock`, `AI_MOCK_BASE_URL`, and
+ `CLOUDFLARE_API_BASE_URL` + `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`
+ to the local Cloudflare API mock Worker for `page_to_markdown`, the internal
+ Cloudflare API client, and local email sending. Unless you already set
+ `CLOUDFLARE_EMAIL_FROM`, the launcher also defaults it to `reset@kody.dev`.
+ Set `SKIP_CLOUDFLARE_MOCK=1` to skip the local Cloudflare mock entirely. The
home connector receives the resolved worker origin via `WORKER_BASE_URL`. When
`HOME_CONNECTOR_SHARED_SECRET` is unset, the launcher generates one and passes
it to both the worker and the connector so the outbound registration handshake
succeeds in local development. The main worker and home connector stream logs
- live; the client bundle and background mock workers now buffer their logs and
- only print them if that child process exits with an error.)
+ live; the client bundle and background mock workers buffer logs and only print
+ them if that child process exits with an error.)
- The home automation connector now lives in `packages/home-connector`.
- `npm run dev:home-connector` starts the local connector app on Node 24 with
`node --watch`, so connector code changes automatically restart the local
diff --git a/package-lock.json b/package-lock.json
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,7 +50,7 @@
"set-cookie-parser": "^3.0.1",
"typescript": "^5.9.3",
"vitest": "^4.1.1",
- "wrangler": "^4.72.0"
+ "wrangler": "^4.81.1"
},
"engines": {
"node": "24.x"
@@ -1261,6 +1261,41 @@
"node": ">=18"
}
},
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": {
+ "version": "4.77.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.77.0.tgz",
+ "integrity": "sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.4.2",
+ "@cloudflare/unenv-preset": "2.16.0",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.27.3",
+ "miniflare": "4.20260317.2",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260317.1"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=20.3.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20260317.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@cloudflare/vitest-pool-workers/node_modules/zod": {
"version": "3.25.76",
"dev": true,
@@ -1357,7 +1392,9 @@
"license": "MIT"
},
"node_modules/@cloudflare/workers-types": {
- "version": "4.20260317.1",
+ "version": "4.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260409.1.tgz",
+ "integrity": "sha512-0rGuppPeip6dqlI6013wC8tE+kbRK+tcaDfqCxKf9sEHDNfSWWUuKgIEDpt6IHHP2O0iYBQpngk5Siv4CL/HGQ==",
"license": "MIT OR Apache-2.0",
"peer": true
},
@@ -3055,10 +3092,6 @@
"resolved": "packages/mock-servers/cloudflare",
"link": true
},
- "node_modules/@kody/mock-resend": {
- "resolved": "packages/mock-servers/resend",
- "link": true
- },
"node_modules/@kody/worker": {
"resolved": "packages/worker",
"link": true
@@ -4211,9 +4244,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4231,9 +4261,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4251,9 +4278,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4271,9 +4295,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4291,9 +4312,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4311,9 +4329,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4331,9 +4346,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4351,9 +4363,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5966,9 +5975,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5983,9 +5989,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5999,9 +6002,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6016,9 +6016,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6033,9 +6030,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6050,9 +6044,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6067,9 +6058,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6084,9 +6072,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6101,9 +6086,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6118,9 +6100,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6135,9 +6114,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6151,9 +6127,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -6168,9 +6141,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -21607,7 +21577,9 @@
}
},
"node_modules/wrangler": {
- "version": "4.77.0",
+ "version": "4.81.1",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.81.1.tgz",
+ "integrity": "sha512-fppPXi+W2KJ5bx1zxdUYe1e7CHj5cWPFVBPXy8hSMZhrHeIojMe3ozAktAOw1voVuQjXzbZJf/GVKyVeSjbF8w==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -21615,10 +21587,10 @@
"@cloudflare/unenv-preset": "2.16.0",
"blake3-wasm": "2.1.5",
"esbuild": "0.27.3",
- "miniflare": "4.20260317.2",
+ "miniflare": "4.20260409.0",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
- "workerd": "1.20260317.1"
+ "workerd": "1.20260409.1"
},
"bin": {
"wrangler": "bin/wrangler.js",
@@ -21631,7 +21603,7 @@
"fsevents": "~2.3.2"
},
"peerDependencies": {
- "@cloudflare/workers-types": "^4.20260317.1"
+ "@cloudflare/workers-types": "^4.20260409.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
@@ -21639,6 +21611,91 @@
}
}
},
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260409.1.tgz",
+ "integrity": "sha512-h/bkaC0HJL63aqAGnV0oagqpBiTSstabODThkeMSbG8kctl0Jb4jlq1pNHJPmYGazFNtfyagrUZFb6HN22GX7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260409.1.tgz",
+ "integrity": "sha512-HTAC+B9uSYcm+GjN3UYJjuun19GqYtK1bAFJ0KECXyfsgIDwH1MTzxbTxzJpZUbWLw8s0jcwCU06MWZj6cgnxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260409.1.tgz",
+ "integrity": "sha512-QIoNq5cgmn1ko8qlngmgZLXQr2KglrjvIwVFOyJI3rbIpt8631n/YMzHPiOWgt38Cb6tcni8fXOzkcvIX2lBDg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260409.1.tgz",
+ "integrity": "sha512-HJGBMTfPDb0GCjwdxWFx63wS20TYDVmtOuA5KVri/CiFnit71y++kmseVmemjsgLFFIzoEAuFG/xUh1FJLo6tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260409.1.tgz",
+ "integrity": "sha512-GttFO0+TvE0rJNQbDlxC6kq2Q7uFxoZRo74Z9d/trUrLgA14HEVTTXobYyiWrDZ9Qp2W5KN1CrXQXiko0zE38Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/wrangler/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -22119,6 +22176,58 @@
"node": ">=18"
}
},
+ "node_modules/wrangler/node_modules/miniflare": {
+ "version": "4.20260409.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260409.0.tgz",
+ "integrity": "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "^0.34.5",
+ "undici": "7.24.4",
+ "workerd": "1.20260409.1",
+ "ws": "8.18.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/wrangler/node_modules/undici": {
+ "version": "7.24.4",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
+ "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/wrangler/node_modules/workerd": {
+ "version": "1.20260409.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260409.1.tgz",
+ "integrity": "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260409.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260409.1",
+ "@cloudflare/workerd-linux-64": "1.20260409.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260409.1",
+ "@cloudflare/workerd-windows-64": "1.20260409.1"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"license": "MIT",
@@ -22350,13 +22459,9 @@
}
},
"packages/mock-servers/cloudflare": {
- "name": "@kody/mock-cloudflare"
- },
- "packages/mock-servers/resend": {
- "name": "@kody/mock-resend",
+ "name": "@kody/mock-cloudflare",
"dependencies": {
- "@kody-internal/shared": "file:../../shared",
- "@kody/worker": "file:../../worker"
+ "@kody-internal/shared": "file:../../shared"
}
... diff truncated: showing 800 of 5931 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 3ae509d. Configure here.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/worker/src/app/handlers/password-reset.ts (2)
60-75:⚠️ Potential issue | 🟡 MinorPII exposed in missing config warning log.
This function logs raw email addresses (
to,from) and the full email body (html). For consistency with the Cloudflare email module, consider redacting these or importing shared redaction helpers.🛡️ Proposed fix
function logMissingEmailConfig(payload: { to: string from: string subject: string html: string }) { console.warn( 'cloudflare-email-from-missing', JSON.stringify({ - to: payload.to, - from: payload.from, + to: '[redacted]', + from: '[redacted]', subject: payload.subject, - body: payload.html, }), ) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/handlers/password-reset.ts` around lines 60 - 75, The logMissingEmailConfig function currently prints PII (raw to/from addresses and full html body); update it to redact sensitive fields instead of logging raw values by using the shared redaction helpers used by the Cloudflare email module (or implement equivalent redaction), e.g., replace payload.to, payload.from and payload.html with their redacted versions before JSON.stringify in logMissingEmailConfig so the warning preserves structure but never exposes email addresses or full email body.
148-175:⚠️ Potential issue | 🟠 MajorReturn value from
sendCloudflareEmailis not checked.The previous review flagged this issue as addressed, but the current code still ignores the return value.
sendCloudflareEmailreturns{ ok: false, error }on delivery failures rather than throwing, so the catch block won't handle them. The audit event at line 168-175 logsresult: 'success'even when email delivery failed.🔧 Proposed fix to check the result
} else { try { - await sendCloudflareEmail( + const sendResult = await sendCloudflareEmail( { accountId: appEnv.CLOUDFLARE_ACCOUNT_ID, apiBaseUrl: appEnv.CLOUDFLARE_API_BASE_URL, apiToken: appEnv.CLOUDFLARE_API_TOKEN, }, { to: normalizedEmail, from: fromEmail, subject: email.subject, html: email.html, text: email.text, }, ) + if (!sendResult.ok) { + console.warn('cloudflare-email-send-failed', { + error: sendResult.error, + skipped: sendResult.skipped, + }) + } } catch (error) { console.warn('cloudflare-email-error', error) } }Note: Consider whether the audit event should reflect email delivery status, or if the current behavior (logging success for the reset request itself regardless of email delivery) is intentional.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/handlers/password-reset.ts` around lines 148 - 175, sendCloudflareEmail's return value is ignored so delivery failures ({ ok: false, error }) won't be caught and logAuditEvent always records result: 'success'; update the try block around sendCloudflareEmail to capture its result (e.g., const res = await sendCloudflareEmail(...)), handle both res.ok === false (log/process res.error) and thrown exceptions (current catch), and set the audit payload passed to logAuditEvent (the call with category: 'auth', action: 'password_reset_request', email: normalizedEmail, ip: requestIp, path: url.pathname) to reflect delivery outcome (e.g., result: 'email_failed' vs 'success') and include error details when available.
♻️ Duplicate comments (1)
packages/worker/src/app/email/cloudflare-email.ts (1)
100-108:⚠️ Potential issue | 🟠 MajorRaw email addresses logged in API failure path.
Similar to
logSkippedEmail, this error log includes rawtoandfromvalues without redaction. Apply the same masking here.🛡️ Proposed fix
console.warn( 'cloudflare-email-api-failed', JSON.stringify({ status: response.status, body: payload, - to: message.to, - from: message.from, + to: redactRecipients(message.to), + from: maskEmail(message.from), subject: message.subject, }), )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/app/email/cloudflare-email.ts` around lines 100 - 108, The console.warn in the Cloudflare email API failure path is logging raw message.to and message.from values; reuse the same redaction used by logSkippedEmail to mask email addresses before logging. Update the console.warn/JSON.stringify payload in the failure branch (the block around the 'cloudflare-email-api-failed' warn) to call the same masking/redaction helper used by logSkippedEmail (use the same function or utility) for both message.to and message.from and include the masked values in the logged object instead of the raw values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 48-59: The logSkippedEmail function currently outputs sensitive
fields (html, text) and raw email addresses; update logSkippedEmail( message:
OutboundEmail ) to stop including body fields and instead redact email
identifiers before logging: remove html and text from the logged payload and
replace message.to and message.from with masked versions (e.g., mask the local
part leaving only first char and the domain, or fully obfuscate except domain)
while still logging non-sensitive metadata like subject; ensure the
JSON.stringify payload only contains to (redacted), from (redacted), and subject
so bodies and raw addresses are not emitted.
---
Outside diff comments:
In `@packages/worker/src/app/handlers/password-reset.ts`:
- Around line 60-75: The logMissingEmailConfig function currently prints PII
(raw to/from addresses and full html body); update it to redact sensitive fields
instead of logging raw values by using the shared redaction helpers used by the
Cloudflare email module (or implement equivalent redaction), e.g., replace
payload.to, payload.from and payload.html with their redacted versions before
JSON.stringify in logMissingEmailConfig so the warning preserves structure but
never exposes email addresses or full email body.
- Around line 148-175: sendCloudflareEmail's return value is ignored so delivery
failures ({ ok: false, error }) won't be caught and logAuditEvent always records
result: 'success'; update the try block around sendCloudflareEmail to capture
its result (e.g., const res = await sendCloudflareEmail(...)), handle both
res.ok === false (log/process res.error) and thrown exceptions (current catch),
and set the audit payload passed to logAuditEvent (the call with category:
'auth', action: 'password_reset_request', email: normalizedEmail, ip: requestIp,
path: url.pathname) to reflect delivery outcome (e.g., result: 'email_failed' vs
'success') and include error details when available.
---
Duplicate comments:
In `@packages/worker/src/app/email/cloudflare-email.ts`:
- Around line 100-108: The console.warn in the Cloudflare email API failure path
is logging raw message.to and message.from values; reuse the same redaction used
by logSkippedEmail to mask email addresses before logging. Update the
console.warn/JSON.stringify payload in the failure branch (the block around the
'cloudflare-email-api-failed' warn) to call the same masking/redaction helper
used by logSkippedEmail (use the same function or utility) for both message.to
and message.from and include the masked values in the logged object instead of
the raw values.
🪄 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
Run ID: 2c11c0cb-9f03-40f5-888c-caa2c835965a
📒 Files selected for processing (5)
packages/worker/src/app/email/cloudflare-email.node.test.tspackages/worker/src/app/email/cloudflare-email.tspackages/worker/src/app/handlers/password-reset.tspackages/worker/src/env-schema.tspackages/worker/worker-configuration.d.ts
✅ Files skipped from review due to trivial changes (1)
- packages/worker/src/env-schema.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/worker/src/app/email/cloudflare-email.node.test.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

Summary
{ ok: false }, keep full message contents available in the mock worker, and clean up local mock-worker startup failures in the focused node test helperCLOUDFLARE_EMAIL_FROMplus Cloudflare Email local behaviorTesting
cloudflare-email-tests.log
cloudflare-email-reset-flow.log
cloudflare-email-followup-tests.log
Summary by CodeRabbit
Refactor
CLOUDFLARE_EMAIL_FROMinstead of Resend-related variables.Chores