Skip to content

Migrate outbound email delivery to Cloudflare Email - #137

Merged
kentcdodds merged 6 commits into
mainfrom
cursor/migrate-cloudflare-email-25e9
Apr 9, 2026
Merged

kentcdodds merged 6 commits into
mainfrom
cursor/migrate-cloudflare-email-25e9

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • replace the Resend-specific password reset email sender with a Cloudflare Email sender that uses the Cloudflare Email REST API in all environments
  • move local/preview outbound email capture into the existing Cloudflare mock worker and remove the standalone Resend mock worker
  • harden Cloudflare API error handling so transport failures and invalid JSON responses return { ok: false }, keep full message contents available in the mock worker, and clean up local mock-worker startup failures in the focused node test helper
  • update Wrangler, worker config, CI secret wiring, and contributor docs for CLOUDFLARE_EMAIL_FROM plus Cloudflare Email local behavior

Testing

Open in Web Open in Cursor 

Summary by CodeRabbit

  • Refactor

    • Switched email sending from Resend to Cloudflare Email Service; update configuration to use CLOUDFLARE_EMAIL_FROM instead of Resend-related variables.
    • Enhanced local Cloudflare mock to support email message storage and dashboard management.
  • Chores

    • Updated Wrangler dependency; removed legacy mock server script from npm commands.

@coderabbitai

coderabbitai Bot commented Apr 9, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@cursor[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 54 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db7f1e6a-5b93-46ea-b6ab-599d9f57f4c4

📥 Commits

Reviewing files that changed from the base of the PR and between 3ae509d and 49b660b.

📒 Files selected for processing (2)
  • packages/worker/src/app/email/cloudflare-email.node.test.ts
  • packages/worker/src/app/handlers/password-reset.ts
📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
GitHub Actions Workflows
.github/workflows/deploy.yml, .github/workflows/preview.yml
Updated secret synchronization to replace Resend env vars (RESEND_API_KEY, RESEND_FROM_EMAIL) with CLOUDFLARE_EMAIL_FROM. Preview workflow now conditionally sets CLOUDFLARE_ACCOUNT_ID=cf_account_mock_123 in mock overrides.
Mock-Resend Server Deletion
packages/mock-servers/resend/*
Removed entire mock-resend server package: package.json, project.json, src/mock-messages-do.ts, src/worker.ts, and wrangler.jsonc. Eliminates Resend email mocking infrastructure.
Cloudflare Mock Server Enhancement
packages/mock-servers/cloudflare/package.json, packages/mock-servers/cloudflare/src/mock-email-messages-do.ts, packages/mock-servers/cloudflare/src/worker.ts, packages/mock-servers/cloudflare/wrangler.jsonc
Added Durable Object for email message storage (MockCloudflareEmailMessagesDurableObject). Extended worker with email-service endpoints (POST /client/v4/accounts/:accountId/email-service/send), dashboard routes (/__mocks/messages, /__mocks/clear), and token-aware message scoping. Added durable-object binding configuration.
CLI Mock Lifecycle
cli.ts
Removed mockResendProcess and Resend-related reuse logic. Updated Cloudflare mock to inject fixed CLOUDFLARE_ACCOUNT_ID ('cf_account_mock_123') and adjusted reuse eligibility to include new account ID. Simplified port allocation and reordered Promise.all initialization sequence.
Email Implementation Refactoring
packages/worker/src/app/email/cloudflare-email.ts, packages/worker/src/app/email/cloudflare-email.node.test.ts, packages/worker/src/app/email/resend.ts
Added new sendCloudflareEmail module with validation, API integration, and comprehensive test suite. Removed entire resend.ts implementation. New function handles Cloudflare account credentials and returns structured responses.
Email Handler Updates
packages/worker/src/app/handlers/password-reset.ts, packages/worker/src/app/handlers/chat-threads.ts
Password-reset handler switched from Resend to Cloudflare email sender with new config vars and added plain-text text body generation. Chat-threads handlers updated with explicit double-cast pattern (appEnv as unknown as Env).
Environment & Schema Updates
packages/worker/src/env-schema.ts, packages/shared/src/outbound-email.ts, packages/worker/.env.example
Replaced three Resend env vars with single CLOUDFLARE_EMAIL_FROM. Renamed resendEmailSchema to outboundEmailSchema and added optional text field. Updated example env file with Cloudflare-specific settings.
Router & Configuration
packages/worker/src/app/router.ts
Applied consistent double-cast pattern (appEnv as unknown as Env) across multiple handler registrations. Reformatted multiline router.map calls for improved readability.
Package & Development
package.json, tools/ci/sync-worker-secrets.node.test.ts
Updated wrangler from ^4.72.0 to ^4.81.1. Removed dev:mock-resend npm script. Updated secret-sync test to validate CLOUDFLARE_EMAIL_FROM instead of RESEND_API_KEY.
Documentation
docs/contributing/setup-manifest.md, docs/contributing/setup.md
Replaced Resend setup instructions with Cloudflare Email Service guidance. Added CLOUDFLARE_ACCOUNT_ID and email-sending details to local development setup docs. Clarified Cloudflare mock configuration and email fallback behavior.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #25: Overlapping code-level changes to Cloudflare mock/server integration, secret wiring, CLI mock lifecycle, and workflow secret mappings; both PRs directly replace Resend with Cloudflare across the same files.
  • PR #39: Modifies packages/worker/src/env-schema.ts for environment variable schema structure and imports; both PRs make related changes to this foundational schema.
  • PR #26: Updates tools/ci/sync-worker-secrets implementation and tests to handle empty optional environment variables with aligned variable name changes.

Poem

🐰 Resend takes a bow, Cloudflare now leads the way,
Durable Objects store messages, come what may,
Mock servers dance in harmony, no more Resend to say,
Email flows through cloud routes—hooray, hooray!
✨📧

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main objective of the PR: migrating outbound email delivery from Resend to Cloudflare Email, which is the primary change across the entire changeset.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/migrate-cloudflare-email-25e9

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 and usage tips.

@kentcdodds
kentcdodds marked this pull request as ready for review April 9, 2026 17:28
@github-actions

github-actions Bot commented Apr 9, 2026 •

Copy link
Copy Markdown
Contributor

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

Worker: kody-pr-137
D1: kody-pr-137-db
KV: kody-pr-137-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 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 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/worker/src/app/email/cloudflare-email.ts Outdated

@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: 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 | 🟠 Major

Don't write reset links to logs when email config is missing.

This path passes email.html into logMissingEmailConfig, and that HTML contains the live reset token. Any environment missing CLOUDFLARE_EMAIL_FROM will 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 | 🟠 Major

Remote 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 real CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN from .env and 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 in router.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 unsafe as unknown as Env casts at the auth boundary.

Lines 24-27, 76-79, and 132-135 use an unsafe type assertion that suppresses structural type checking. If AppEnv and Env contracts diverge, the mismatch won't be caught at compile time. All other handlers (chat-agent.ts, account-secrets.ts, connect-secret.ts, etc.) successfully call readAuthenticatedAppUser by passing env directly without casting.

♻️ Recommended approach

Either:

Option A: Pass raw env directly (matches other call sites):

- const user = await readAuthenticatedAppUser(request, appEnv as unknown as Env)
+ const user = await readAuthenticatedAppUser(request, env)

Option B: Refactor readAuthenticatedAppUser to accept AppEnv (or Pick<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

📥 Commits

Reviewing files that changed from the base of the PR and between 5863c62 and ed9b629.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (27)
  • .github/workflows/deploy.yml
  • .github/workflows/preview.yml
  • cli.ts
  • docs/contributing/setup-manifest.md
  • docs/contributing/setup.md
  • package.json
  • packages/mock-servers/cloudflare/package.json
  • packages/mock-servers/cloudflare/src/mock-email-messages-do.ts
  • packages/mock-servers/cloudflare/src/worker.ts
  • packages/mock-servers/cloudflare/wrangler.jsonc
  • packages/mock-servers/resend/package.json
  • packages/mock-servers/resend/project.json
  • packages/mock-servers/resend/src/mock-messages-do.ts
  • packages/mock-servers/resend/src/worker.ts
  • packages/mock-servers/resend/wrangler.jsonc
  • packages/shared/src/outbound-email.ts
  • packages/worker/.env.example
  • packages/worker/src/app/email/cloudflare-email.node.test.ts
  • packages/worker/src/app/email/cloudflare-email.ts
  • packages/worker/src/app/email/resend.ts
  • packages/worker/src/app/handlers/chat-threads.ts
  • packages/worker/src/app/handlers/password-reset.ts
  • packages/worker/src/app/router.ts
  • packages/worker/src/env-schema.ts
  • packages/worker/worker-configuration.d.ts
  • packages/worker/wrangler.jsonc
  • tools/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

Comment thread packages/worker/src/app/email/cloudflare-email.node.test.ts
Comment thread packages/worker/src/app/handlers/password-reset.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@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

♻️ Duplicate comments (1)
packages/worker/src/app/email/cloudflare-email.ts (1)

61-69: ⚠️ Potential issue | 🟠 Major

Remove 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

📥 Commits

Reviewing files that changed from the base of the PR and between ed9b629 and 227dd23.

📒 Files selected for processing (1)
  • packages/worker/src/app/email/cloudflare-email.ts

Comment thread packages/worker/src/app/email/cloudflare-email.ts Outdated

@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

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between 227dd23 and d5cd763.

📒 Files selected for processing (2)
  • packages/worker/src/app/email/cloudflare-email.node.test.ts
  • packages/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

Comment thread packages/worker/src/app/email/cloudflare-email.ts

@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 and found 1 potential issue.

Fix All in Cursor

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 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 3ae509d. Configure here.

Comment thread packages/worker/src/app/handlers/password-reset.ts

@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

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 | 🟡 Minor

PII 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 | 🟠 Major

Return value from sendCloudflareEmail is not checked.

The previous review flagged this issue as addressed, but the current code still ignores the return value. sendCloudflareEmail returns { ok: false, error } on delivery failures rather than throwing, so the catch block won't handle them. The audit event at line 168-175 logs result: '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 | 🟠 Major

Raw email addresses logged in API failure path.

Similar to logSkippedEmail, this error log includes raw to and from values 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

📥 Commits

Reviewing files that changed from the base of the PR and between d5cd763 and 3ae509d.

📒 Files selected for processing (5)
  • packages/worker/src/app/email/cloudflare-email.node.test.ts
  • packages/worker/src/app/email/cloudflare-email.ts
  • packages/worker/src/app/handlers/password-reset.ts
  • packages/worker/src/env-schema.ts
  • packages/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

Comment thread packages/worker/src/app/email/cloudflare-email.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds merged commit 6310506 into main Apr 9, 2026
9 checks passed
@kentcdodds
kentcdodds deleted the cursor/migrate-cloudflare-email-25e9 branch April 9, 2026 21:12
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