feat: add Cloudflare Worker for Resend webhook push notifications#20
feat: add Cloudflare Worker for Resend webhook push notifications#20yushanwebdev wants to merge 8 commits intomainfrom
Conversation
Add a Cloudflare Worker that receives Resend email webhooks (verified via Svix), stores the user's Expo push token in Workers KV, and sends push notifications when new emails arrive. On the client side, incoming notifications now insert email metadata directly into SQLite so the inbox updates instantly without polling. https://claude.ai/code/session_01Ek7JZyGJyN82CEkpExxQKM
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Secure the /api/push-token endpoint with a shared API key validated via Hono's bearerAuth middleware, preventing unauthorized token registration from arbitrary callers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace raw fetch to Expo Push API with the official expo-server-sdk, gaining automatic retries, gzip compression, and proper token validation. Configure Expo access token for authenticated push sends. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SDK's undici dependency uses MessagePort which is unavailable in the Cloudflare Workers runtime. Replace with native fetch using Expo access token auth and SDK-compatible push token validation (bracket and UUID formats). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevent Cloudflare Worker development secrets from being committed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the startsWith check with the exported isExpoPushToken helper that validates both bracket and UUID token formats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe PR introduces a Cloudflare Worker backend to handle push notifications for the yumail app. It adds environment variables for worker configuration, establishes HTTP endpoints for push token registration and webhook verification from Resend, and updates the mobile app to register tokens with the worker and handle foreground notifications by inserting email records. Changes
Sequence Diagram(s)sequenceDiagram
participant App as Mobile App
participant Worker as Cloudflare Worker
participant KV as KV Storage
participant Resend as Resend Service
participant Expo as Expo Push Service
rect rgba(100, 150, 200, 0.5)
Note over App,KV: Push Token Registration Flow
App->>Worker: POST /api/push-token (Bearer auth)
Worker->>Worker: Validate Expo token format
Worker->>KV: Store token under 'latest' key
KV-->>Worker: Acknowledged
Worker-->>App: { success: true }
end
rect rgba(200, 150, 100, 0.5)
Note over Resend,Expo: Email Webhook → Push Notification Flow
Resend->>Worker: POST /api/webhooks/resend (email.received)
Worker->>Worker: Verify webhook signature (Svix)
Worker->>KV: Retrieve latest push token
KV-->>Worker: Token data
Worker->>Expo: POST /send (push notification + email data)
Expo-->>Worker: Response
Worker-->>Resend: { success: true }
end
rect rgba(150, 200, 100, 0.5)
Note over App: Foreground Notification Handling
App->>App: Receive foreground notification
App->>App: Extract emailId, parse createdAt
App->>App: Insert email record via insertEmails()
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app.config.ts (1)
77-82:⚠️ Potential issue | 🔴 CriticalRemove
WORKER_API_KEYfrom Expoextraand implement proper server-side authentication.Values placed under
extrain the app config are bundled into the public manifest and readable at runtime viaConstants.expoConfig.extra. This exposes the/api/push-tokenbearer token as a static secret in the client, allowing anyone who extracts the app to register arbitrary Expo tokens and access email notification metadata. Implement real server-side authentication instead of embedding secrets in the client config.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app.config.ts` around lines 77 - 82, Remove the workerApiKey entry from the Expo app config extra block (the extra.eas.workerApiKey / workerApiKey symbol) so the secret is not bundled into Constants.expoConfig.extra; instead, store the secret only on the server (env var used by server-side code) and change the /api/push-token endpoint handler (the route that registers push tokens) to authenticate incoming requests server-side using proper auth (e.g., user session/JWT or an API key validated against server-only env var) rather than trusting a client-supplied bearer token extracted from the app bundle; rotate the secret and update any server code that currently reads expoConfig.extra.workerApiKey to read process.env on the server and reject unauthenticated requests.
🧹 Nitpick comments (2)
worker/src/webhooks.ts (1)
19-25: Unchecked type cast may cause runtime errors if payload shape differs.The
wh.verify()return is cast directly toEmailReceivedEventwithout runtime validation. If Resend sends an unexpected event type or malformed payload, downstream code accessingevent.data.email_idetc. will fail with unclear errors.Consider validating the payload structure before returning, or at minimum check that required fields exist.
♻️ Suggested validation approach
try { - const event = wh.verify(payload, { + const raw = wh.verify(payload, { 'svix-id': svixId, 'svix-timestamp': svixTimestamp, 'svix-signature': svixSignature, - }) as EmailReceivedEvent; + }); + + // Basic runtime validation + if ( + typeof raw !== 'object' || + raw === null || + typeof (raw as Record<string, unknown>).type !== 'string' || + typeof (raw as Record<string, unknown>).data !== 'object' + ) { + throw new WebhookError('Invalid webhook payload structure', 400); + } + + const event = raw as EmailReceivedEvent; return event;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@worker/src/webhooks.ts` around lines 19 - 25, The code casts the result of wh.verify(...) directly to EmailReceivedEvent which can blow up if the payload shape differs; add a runtime check (e.g., implement an isEmailReceivedEvent type guard) that verifies event.type is the expected value (like 'email.received') and required fields exist (e.g., event.data and event.data.email_id) before returning; if validation fails, throw a clear error or return a safe fallback value and update any callers of the function that assume EmailReceivedEvent to handle the validated/guarded result.worker/src/push.ts (1)
58-60: Silent failure on push send may warrant alerting.When
response.okis false, the error is logged but the function returns void. For a production system, consider whether failed push notifications should trigger alerts or be tracked in metrics, especially if they indicate misconfiguration (invalid token, expired access token, etc.).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@worker/src/push.ts` around lines 58 - 60, The code currently logs failed push responses but returns void; update the push-sending function (e.g., sendPushNotification / the function that awaits response) to surface failures: when response.ok is false, capture response.text() and either throw a descriptive Error (including status and body) or return an explicit failure result (boolean or Result object), and also emit/record a metric or alert (e.g., increment a push_failure counter or call alerting/monitoring API) so production failures are tracked; ensure you reference the existing response and response.ok checks and preserve the logged message while adding the thrown error/returned failure and the metrics/alerting call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@hooks/useNotifications.ts`:
- Around line 106-121: The current insertEmails call only runs inside
Notifications.addNotificationReceivedListener (notificationListener.current) so
notifications tapped from the tray (handled via getLastNotificationResponse()
and addNotificationResponseReceivedListener()) never persist; extract the insert
logic into a small helper (e.g. persistNotificationEmail(data) or
persistNotificationIfEmail) and call it from the existing notification-received
listener, from handleNotificationResponse, and when processing
getLastNotificationResponse()/addNotificationResponseReceivedListener() flows so
tapped notifications also create the SQLite row; reference
notificationListener.current, Notifications.addNotificationReceivedListener,
getLastNotificationResponse, addNotificationResponseReceivedListener,
handleNotificationResponse, and insertEmails when updating the code.
- Around line 59-70: The current fire-and-forget fetch to
`${workerUrl}/api/push-token` treats any HTTP error as success; update the
registration logic (the fetch call that uses workerUrl and workerApiKey) to
await the response, check response.ok (or specific status codes), and handle
non-OK responses by logging the response status/body and optionally throwing or
retrying; ensure errors are caught and surfaced (replace the current .catch-only
approach with an async try/catch around await fetch and parse error details from
the response for more actionable diagnostics).
In `@worker/src/index.ts`:
- Around line 13-18: The call to c.req.json() can throw on malformed JSON
leading to a 500; wrap the JSON parse in a try/catch inside the async request
handler (the anonymous async (c) function) and on a SyntaxError or parse failure
return c.json({ error: 'Invalid JSON' }, 400); then continue to validate
body.token with isExpoPushToken as before so malformed requests produce a 400
instead of an unhandled exception.
In `@worker/src/push.ts`:
- Around line 27-30: The error log in push.ts currently prints the full push
token when isExpoPushToken(token) fails; change the logging in the early-return
block inside the function that validates tokens (the isExpoPushToken check) to
avoid exposing the full token by either logging a masked/truncated version
(e.g., show first 4 and last 4 chars) or a generic message like "Invalid Expo
push token" with no token payload, and keep the existing console.error
call/context so the function still returns early on invalid tokens.
---
Outside diff comments:
In `@app.config.ts`:
- Around line 77-82: Remove the workerApiKey entry from the Expo app config
extra block (the extra.eas.workerApiKey / workerApiKey symbol) so the secret is
not bundled into Constants.expoConfig.extra; instead, store the secret only on
the server (env var used by server-side code) and change the /api/push-token
endpoint handler (the route that registers push tokens) to authenticate incoming
requests server-side using proper auth (e.g., user session/JWT or an API key
validated against server-only env var) rather than trusting a client-supplied
bearer token extracted from the app bundle; rotate the secret and update any
server code that currently reads expoConfig.extra.workerApiKey to read
process.env on the server and reject unauthenticated requests.
---
Nitpick comments:
In `@worker/src/push.ts`:
- Around line 58-60: The code currently logs failed push responses but returns
void; update the push-sending function (e.g., sendPushNotification / the
function that awaits response) to surface failures: when response.ok is false,
capture response.text() and either throw a descriptive Error (including status
and body) or return an explicit failure result (boolean or Result object), and
also emit/record a metric or alert (e.g., increment a push_failure counter or
call alerting/monitoring API) so production failures are tracked; ensure you
reference the existing response and response.ok checks and preserve the logged
message while adding the thrown error/returned failure and the metrics/alerting
call.
In `@worker/src/webhooks.ts`:
- Around line 19-25: The code casts the result of wh.verify(...) directly to
EmailReceivedEvent which can blow up if the payload shape differs; add a runtime
check (e.g., implement an isEmailReceivedEvent type guard) that verifies
event.type is the expected value (like 'email.received') and required fields
exist (e.g., event.data and event.data.email_id) before returning; if validation
fails, throw a clear error or return a safe fallback value and update any
callers of the function that assume EmailReceivedEvent to handle the
validated/guarded result.
🪄 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: c4212cda-2fa3-46e5-b4c6-226b2265ddb8
⛔ Files ignored due to path filters (1)
worker/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (12)
.env.example.gitignoreapp.config.tsdb/syncEngine.tshooks/useNotifications.tsworker/package.jsonworker/src/index.tsworker/src/push.tsworker/src/types.tsworker/src/webhooks.tsworker/tsconfig.jsonworker/wrangler.toml
| // Register push token with the Cloudflare Worker | ||
| const workerUrl = process.env.EXPO_PUBLIC_WORKER_URL; | ||
| const workerApiKey = Constants.expoConfig?.extra?.workerApiKey; | ||
| if (workerUrl) { | ||
| fetch(`${workerUrl}/api/push-token`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| ...(workerApiKey && { Authorization: `Bearer ${workerApiKey}` }), | ||
| }, | ||
| body: JSON.stringify({ token }), | ||
| }).catch((err) => console.warn('Failed to register push token with server:', err)); |
There was a problem hiding this comment.
Check /api/push-token responses instead of fire-and-forget.
fetch only rejects on transport failures. A 400/401/500 from the worker is currently treated as success, so push registration can fail silently until the next app launch.
💡 Proposed fix
if (workerUrl) {
- fetch(`${workerUrl}/api/push-token`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(workerApiKey && { Authorization: `Bearer ${workerApiKey}` }),
- },
- body: JSON.stringify({ token }),
- }).catch((err) => console.warn('Failed to register push token with server:', err));
+ try {
+ const response = await fetch(`${workerUrl}/api/push-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(workerApiKey && { Authorization: `Bearer ${workerApiKey}` }),
+ },
+ body: JSON.stringify({ token }),
+ });
+ if (!response.ok) {
+ console.warn(
+ 'Failed to register push token with server:',
+ response.status,
+ await response.text(),
+ );
+ }
+ } catch (err) {
+ console.warn('Failed to register push token with server:', err);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Register push token with the Cloudflare Worker | |
| const workerUrl = process.env.EXPO_PUBLIC_WORKER_URL; | |
| const workerApiKey = Constants.expoConfig?.extra?.workerApiKey; | |
| if (workerUrl) { | |
| fetch(`${workerUrl}/api/push-token`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(workerApiKey && { Authorization: `Bearer ${workerApiKey}` }), | |
| }, | |
| body: JSON.stringify({ token }), | |
| }).catch((err) => console.warn('Failed to register push token with server:', err)); | |
| // Register push token with the Cloudflare Worker | |
| const workerUrl = process.env.EXPO_PUBLIC_WORKER_URL; | |
| const workerApiKey = Constants.expoConfig?.extra?.workerApiKey; | |
| if (workerUrl) { | |
| try { | |
| const response = await fetch(`${workerUrl}/api/push-token`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(workerApiKey && { Authorization: `Bearer ${workerApiKey}` }), | |
| }, | |
| body: JSON.stringify({ token }), | |
| }); | |
| if (!response.ok) { | |
| console.warn( | |
| 'Failed to register push token with server:', | |
| response.status, | |
| await response.text(), | |
| ); | |
| } | |
| } catch (err) { | |
| console.warn('Failed to register push token with server:', err); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@hooks/useNotifications.ts` around lines 59 - 70, The current fire-and-forget
fetch to `${workerUrl}/api/push-token` treats any HTTP error as success; update
the registration logic (the fetch call that uses workerUrl and workerApiKey) to
await the response, check response.ok (or specific status codes), and handle
non-OK responses by logging the response status/body and optionally throwing or
retrying; ensure errors are caught and surfaced (replace the current .catch-only
approach with an async try/catch around await fetch and parse error details from
the response for more actionable diagnostics).
| notificationListener.current = Notifications.addNotificationReceivedListener((notification) => { | ||
| const data = notification.request.content.data; | ||
| if (data?.emailId && typeof data.emailId === 'string') { | ||
| const parsed = data.createdAt ? parseDate(data.createdAt as string) : null; | ||
| const ms = parsed ? parsed.getTime() : Date.now(); | ||
| const createdDate = parsed ? toLocalDateString(parsed) : toLocalDateString(new Date()); | ||
| insertEmails([{ | ||
| id: data.emailId, | ||
| from_address: (data.from as string) || '', | ||
| subject: (data.subject as string) || '(No subject)', | ||
| snippet: '', | ||
| created_date: createdDate, | ||
| created_at_ms: ms, | ||
| is_read: 0, | ||
| }]); | ||
| } |
There was a problem hiding this comment.
Persist tapped notifications too.
This insert only runs in the notification-received path. The cold-start and tap flows (getLastNotificationResponse() and addNotificationResponseReceivedListener()) still go straight to handleNotificationResponse(), so emails opened from the tray never get the SQLite row this feature depends on until a later sync.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@hooks/useNotifications.ts` around lines 106 - 121, The current insertEmails
call only runs inside Notifications.addNotificationReceivedListener
(notificationListener.current) so notifications tapped from the tray (handled
via getLastNotificationResponse() and addNotificationResponseReceivedListener())
never persist; extract the insert logic into a small helper (e.g.
persistNotificationEmail(data) or persistNotificationIfEmail) and call it from
the existing notification-received listener, from handleNotificationResponse,
and when processing
getLastNotificationResponse()/addNotificationResponseReceivedListener() flows so
tapped notifications also create the SQLite row; reference
notificationListener.current, Notifications.addNotificationReceivedListener,
getLastNotificationResponse, addNotificationResponseReceivedListener,
handleNotificationResponse, and insertEmails when updating the code.
| async (c) => { | ||
| const body = await c.req.json<{ token?: string }>(); | ||
|
|
||
| if (!body.token || !isExpoPushToken(body.token)) { | ||
| return c.json({ error: 'Invalid Expo push token' }, 400); | ||
| } |
There was a problem hiding this comment.
JSON parsing can throw on malformed requests.
c.req.json() throws if the request body is not valid JSON, resulting in an unhandled error that would return a 500 instead of a more appropriate 400.
🛡️ Suggested fix with error handling
async (c) => {
- const body = await c.req.json<{ token?: string }>();
+ let body: { token?: string };
+ try {
+ body = await c.req.json<{ token?: string }>();
+ } catch {
+ return c.json({ error: 'Invalid JSON body' }, 400);
+ }
if (!body.token || !isExpoPushToken(body.token)) {
return c.json({ error: 'Invalid Expo push token' }, 400);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async (c) => { | |
| const body = await c.req.json<{ token?: string }>(); | |
| if (!body.token || !isExpoPushToken(body.token)) { | |
| return c.json({ error: 'Invalid Expo push token' }, 400); | |
| } | |
| async (c) => { | |
| let body: { token?: string }; | |
| try { | |
| body = await c.req.json<{ token?: string }>(); | |
| } catch { | |
| return c.json({ error: 'Invalid JSON body' }, 400); | |
| } | |
| if (!body.token || !isExpoPushToken(body.token)) { | |
| return c.json({ error: 'Invalid Expo push token' }, 400); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@worker/src/index.ts` around lines 13 - 18, The call to c.req.json() can throw
on malformed JSON leading to a 500; wrap the JSON parse in a try/catch inside
the async request handler (the anonymous async (c) function) and on a
SyntaxError or parse failure return c.json({ error: 'Invalid JSON' }, 400); then
continue to validate body.token with isExpoPushToken as before so malformed
requests produce a 400 instead of an unhandled exception.
| if (!isExpoPushToken(token)) { | ||
| console.error('Invalid Expo push token:', token); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Avoid logging sensitive token values.
Logging the full push token in error scenarios could expose sensitive device identifiers in production logs. Consider logging only a truncated portion or just indicating an invalid token was found.
🛡️ Suggested fix
if (!isExpoPushToken(token)) {
- console.error('Invalid Expo push token:', token);
+ console.error('Invalid Expo push token format detected');
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!isExpoPushToken(token)) { | |
| console.error('Invalid Expo push token:', token); | |
| return; | |
| } | |
| if (!isExpoPushToken(token)) { | |
| console.error('Invalid Expo push token format detected'); | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@worker/src/push.ts` around lines 27 - 30, The error log in push.ts currently
prints the full push token when isExpoPushToken(token) fails; change the logging
in the early-return block inside the function that validates tokens (the
isExpoPushToken check) to avoid exposing the full token by either logging a
masked/truncated version (e.g., show first 4 and last 4 chars) or a generic
message like "Invalid Expo push token" with no token payload, and keep the
existing console.error call/context so the function still returns early on
invalid tokens.
Summary
worker/) that receives Resend webhook events (via Svix verification) and sends Expo push notifications when new emails arrive/api/push-tokenendpointTest plan
wrangler deployand verify it starts/api/push-tokenare rejected🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes