feat(backend): Resend email delivery webhooks + EmailOutbox matching#1341
feat(backend): Resend email delivery webhooks + EmailOutbox matching#1341mantrakp04 wants to merge 3 commits intodevfrom
Conversation
- Extend Resend webhook handler for email.delivered, delivery_delayed, bounced, and complained events; match EmailOutbox rows and set timestamps. - Keep canHaveDeliveryInfo true for Resend sends so rows stay matchable. Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds support for Resend email delivery webhooks (multiple email.* event kinds) alongside existing domain.updated handling; implements recipient matching and conditional EmailOutbox timestamp updates, tolerant timestamp parsing, and error capture. Also marks outbox rows as able to receive delivery info when provider is Resend. Changes
Sequence DiagramsequenceDiagram
actor Resend as Resend
participant WebhookAPI as Webhook API
participant Parser as Payload Parser
participant Matcher as Recipient Matcher
participant DB as Prisma / Raw SQL
participant ErrorLog as Error Capture
Resend->>WebhookAPI: POST webhook (domain.* or email.*)
WebhookAPI->>Parser: validate & normalize payload
Parser->>Parser: parseEventTimestamp()
Parser->>Matcher: provide event kind, recipients, ts
Matcher->>DB: CTE select best EmailOutbox within 96h
DB-->>Matcher: candidate row or none
alt candidate found
Matcher->>DB: conditional UPDATE delivery timestamps
DB-->>WebhookAPI: success
else no candidate
Matcher->>ErrorLog: captureError(missing recipient)
ErrorLog-->>WebhookAPI: logged
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
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 |
Greptile SummaryThis PR adds Resend email delivery webhook handling ( Confidence Score: 5/5Safe to merge; all prior P0/P1 findings are resolved and remaining findings are P2 improvements. The three previously flagged correctness issues (terminalGuard for complained, race condition, hostname substring check) are all fixed. The two remaining comments are P2: noisy captureError on no-match and the cross-tenant recipient-matching ambiguity, both of which are design trade-offs rather than current defects. No P0 or P1 issues remain. No files require special attention beyond the P2 suggestions in route.tsx. Important Files Changed
Sequence DiagramsequenceDiagram
participant Resend
participant Webhook as POST /integrations/resend/webhooks
participant DB as EmailOutbox (Postgres)
Resend->>Webhook: POST email.* event (svix-signed)
Webhook->>Webhook: ensureResendWebhookSignature()
Webhook->>Webhook: emailEventKindFromType(type)
Webhook->>Webhook: parseEventTimestamp(data.created_at)
loop for each recipient in data.to
Webhook->>DB: CTE SELECT candidate WHERE canHaveDeliveryInfo=TRUE AND finishedSendingAt >= eventAt-96h AND terminalGuard AND selfGuard AND recipient matches ORDER BY finishedSendingAt DESC LIMIT 1
DB-->>Webhook: candidate row (or empty)
Webhook->>DB: UPDATE EmailOutbox SET deliveredAt/bouncedAt/etc = eventAt WHERE id=candidate.id AND terminalGuard AND selfGuard (re-check)
DB-->>Webhook: updated row (or 0 rows on race/already-terminal)
alt no rows updated
Webhook->>Webhook: captureError(no-match)
end
end
Webhook-->>Resend: 200 received true
Note over Webhook,DB: canHaveDeliveryInfo set at send time only when host === smtp.resend.com
Prompt To Fix All With AIThis is a comment left during a code review.
Path: apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
Line: 170-178
Comment:
**`captureError` on no-match will flood error tracking**
Every webhook event for a Resend test email, an email sent before this feature was deployed (no `canHaveDeliveryInfo = TRUE`), or any email whose outbox row has already reached a terminal state will hit this path and emit a `StackAssertionError` to the error tracker. In production this could generate a very high volume of spurious alerts and obscure real errors.
Consider logging at a lower level (e.g., a structured info log or a metric counter) rather than treating no-match as a capturable error. If error capture is intentional for visibility, add a rate-limiting or sampling guard.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
Line: 127-157
Comment:
**Cross-tenant ambiguity when matching by recipient email**
The CTE matches `EmailOutbox` rows solely by recipient email address and time window, with no tenant scope. If two different tenants both send an email to the same address (e.g., a shared distribution list) within the 96-hour window, `LIMIT 1 ORDER BY finishedSendingAt DESC` picks the most recent row globally — potentially attributing the delivery event to the wrong tenant's outbox entry.
Resend's `email_id` (available in `payload.data?.email_id`) is a precise, per-message identifier. Storing it at send time in `EmailOutbox` and using it as the primary match key would eliminate ambiguity entirely. The recipient+time heuristic could serve as a fallback when `email_id` is absent.
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "Enhance email delivery event processing ..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
Adds support for Resend webhook-driven email delivery status updates by linking incoming email.* events back to EmailOutbox rows, and keeps existing managed-domain onboarding handling.
Changes:
- Mark
EmailOutbox.canHaveDeliveryInfoastruewhen sending via Resend so rows remain eligible for delivery updates. - Extend the Resend webhook handler to process
email.delivered,email.delivery_delayed,email.bounced, andemail.complainedevents and update matchingEmailOutboxtimestamps. - Preserve existing
domain.updatedwebhook behavior for managed domain onboarding.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| apps/backend/src/lib/email-queue-step.tsx | Sets canHaveDeliveryInfo based on whether the SMTP host indicates Resend. |
| apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx | Adds email delivery webhooks handling and attempts to match/update EmailOutbox delivery timestamps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx (1)
177-183: Consider logging when timestamp parsing falls back to current time.The silent fallback to
new Date()is a reasonable degradation strategy, but it could mask issues if Resend changes their timestamp format. Logging when the fallback is used would help with debugging unexpected matching behavior.🔧 Optional: add observability for fallback case
function parseEventTimestamp(raw: string | undefined): Date { if (raw) { const parsed = new Date(raw); if (!isNaN(parsed.getTime())) return parsed; + captureError("resend-webhook-timestamp-parse-failure", new StackAssertionError("Failed to parse Resend webhook timestamp, using current time", { raw })); } return new Date(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx` around lines 177 - 183, Update parseEventTimestamp to log when it fails to parse the provided raw timestamp and falls back to new Date(): detect the case when raw is truthy but new Date(raw) is invalid, and call the existing logger (or import/create one) to emit a warning including the raw value and a short context message before returning new Date(); keep behavior unchanged when raw is undefined or valid. Ensure the log uses the parseEventTimestamp function name and the raw timestamp value to aid debugging.apps/backend/src/lib/email-queue-step.tsx (1)
733-745: Consider case-insensitive host matching for robustness.The substring check
host.includes("resend")works for typical Resend SMTP hosts (e.g.,smtp.resend.com), but could miss edge cases if the host is configured with different casing. A case-insensitive check would be more defensive.🔧 Optional: case-insensitive check
// Success - mark as sent (don't increment sendRetries since this wasn't a failure) // Resend delivers async webhook events (email.delivered/bounced/...); everything else is fire-and-forget. - const providerCanDeliverInfo = context.emailConfig.host.includes("resend"); + const providerCanDeliverInfo = context.emailConfig.host.toLowerCase().includes("resend");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/backend/src/lib/email-queue-step.tsx` around lines 733 - 745, The providerCanDeliverInfo calculation uses a case-sensitive substring check (context.emailConfig.host.includes("resend")); make it case-insensitive by normalizing the host string (e.g., context.emailConfig.host?.toLowerCase()) before checking for "resend" or use a case-insensitive regex, and guard against null/undefined host to avoid crashes; update the code that sets providerCanDeliverInfo and leave the rest of the emailOutbox.update call unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`:
- Around line 177-183: Update parseEventTimestamp to log when it fails to parse
the provided raw timestamp and falls back to new Date(): detect the case when
raw is truthy but new Date(raw) is invalid, and call the existing logger (or
import/create one) to emit a warning including the raw value and a short context
message before returning new Date(); keep behavior unchanged when raw is
undefined or valid. Ensure the log uses the parseEventTimestamp function name
and the raw timestamp value to aid debugging.
In `@apps/backend/src/lib/email-queue-step.tsx`:
- Around line 733-745: The providerCanDeliverInfo calculation uses a
case-sensitive substring check (context.emailConfig.host.includes("resend"));
make it case-insensitive by normalizing the host string (e.g.,
context.emailConfig.host?.toLowerCase()) before checking for "resend" or use a
case-insensitive regex, and guard against null/undefined host to avoid crashes;
update the code that sets providerCanDeliverInfo and leave the rest of the
emailOutbox.update call unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ff660754-1108-4907-bb6d-6074a20c196a
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxapps/backend/src/lib/email-queue-step.tsx
- Updated terminal state handling to allow for 'complained' events to be processed without overwriting terminal states. - Improved timestamp parsing error handling by capturing errors when invalid timestamps are encountered. - Refined email delivery success condition to check for specific SMTP host.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx (2)
234-239: Silent drop for unknowntypevalues.When
payload.typeis set to something we don’t recognise (neitherdomain.updatednor a knownemail.*), we currently return 200 with no trace. OnecaptureError(or at minimum aconsole.warn) before falling through would make it obvious when Resend adds a new event kind or when a misrouted webhook hits this endpoint.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx` around lines 234 - 239, The handler currently silently ignores unknown payload.type values; before returning 200 in the else branch that computes const kind = eventType ? emailEventKindFromType(eventType) : null, add a warning/error capture so unknown event kinds are visible: call captureError (or at minimum console.warn) including the raw payload.type and payload body when emailEventKindFromType returns null (or eventType is falsy) so operators see when an unrecognized or new Resend event arrives; update the branch around emailEventKindFromType and processEmailDeliveryEvent to log/capture and then continue returning 200.
81-98:email.complainedonly writesmarkedAsSpamAt— consider surfacing a suppression signal too.A
complainedevent means the recipient hit the spam button; in most production setups you’d want to (a) add the address to a suppression list and/or (b) auto-unsubscribe them from the relevant notification category so you don’t keep sending and further hurt sender reputation. Right now this only updates a timestamp column.Fine to defer to a follow-up PR, but worth tracking — silently continuing to send to a complained address is both a deliverability risk and, depending on jurisdiction, a compliance one.
Want me to open a follow-up issue for wiring
email.complainedinto the notification-preferences / suppression flow?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx` around lines 81 - 98, In processEmailDeliveryEvent, when kind === "complained" (currently only setting markedAsSpamAt), also record a suppression/unsubscribe signal: call or implement a suppression helper (e.g., addToSuppressionList(email) or upsertSuppressionRecord) and invoke the notification-preferences/unsubscribe flow (e.g., unsubscribeRecipientFromNotifications(recipientEmail, { source: "resend.complained" })) for each recipient before returning; ensure the suppression call uses the same recipient(s) derived from rawTo and include the payload/context for auditing so complaints both set markedAsSpamAt and prevent further sends.apps/backend/src/lib/email-queue-step.tsx (1)
733-745: Extract the"smtp.resend.com"literal into a shared constant.The same host literal is hardcoded in
apps/backend/src/lib/emails.tsx(managed-email config) and now here; if the managed host ever changes (or is extended to e.g. an EU-region variant likesmtp.eu.resend.com), both sites must be edited in lockstep and thecanHaveDeliveryInfocontract silently breaks on mismatch. A single exported constant (or a smallproviderCanDeliverInfo(emailConfig)helper co-located withgetEmailConfig) would keep them in sync.♻️ Sketch
- // Resend delivers async webhook events (email.delivered/bounced/...); everything else is fire-and-forget. - const providerCanDeliverInfo = context.emailConfig.host === "smtp.resend.com"; + // Resend delivers async webhook events (email.delivered/bounced/...); everything else is fire-and-forget. + const providerCanDeliverInfo = context.emailConfig.host === RESEND_SMTP_HOST;with
RESEND_SMTP_HOSTexported fromlib/emails.tsxand reused in the managed-email branch there.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/backend/src/lib/email-queue-step.tsx` around lines 733 - 745, Extract the hardcoded "smtp.resend.com" into a single exported symbol and reuse it from both places; e.g., add and export RESEND_SMTP_HOST (or a small helper providerCanDeliverInfo(emailConfig)) next to getEmailConfig in lib/emails.tsx, then replace the literal in email-queue-step.tsx where providerCanDeliverInfo is computed and in the managed-email branch in lib/emails.tsx so both files import and use the shared constant/helper to determine canHaveDeliveryInfo.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`:
- Around line 127-168: The candidate CTE can match emails sent after the webhook
event; add an upper bound on finishedSendingAt to ensure causality by requiring
o."finishedSendingAt" <= ${eventAt} (in addition to the existing >=
${windowStart}) inside the candidate WHERE clause. Update the SQL in the block
that assigns to updated (the candidate CTE used by the UPDATE of "EmailOutbox")
so the candidate selection uses both windowStart and eventAt while keeping the
existing terminalGuard and selfGuard logic.
- Around line 35-53: The webhook parsing currently JSON.parse(...) as
ResendWebhookPayload after declaring body as yupMixed(), which bypasses runtime
checks; replace this by defining a yup.object() schema for ResendWebhookPayload
(at least require/shape data.to, data.created_at, data.email_id) and after
JSON.parse run schema.validateSync/validate (or async validate) to
coerce/validate the payload instead of using an `as` cast; update the code paths
that read payload.type, payload.data?.to, payload.data?.created_at to use the
validated result and remove the type cast so downstream accesses are type-safe
and validated.
- Around line 115-168: The webhook matching should prioritize Resend's
authoritative data.email_id instead of only using recipient+time heuristics: add
a new providerMessageId column on EmailOutbox and persist Resend's email_id when
lowLevelSendEmailDirectWithoutRetries returns (store it alongside the outbox row
in the send path), then modify the matching in the route.tsx handler to first
attempt UPDATE ... WHERE e."providerMessageId" = ${data.email_id} (with existing
terminalGuard/selfGuard) and only if that returns no rows fall back to the
current recipient/time-window query (using normalizedRecipient, windowStart,
terminalGuard, selfGuard, updateColumn, eventAt) so webhooks reliably map to the
exact sent message.
---
Nitpick comments:
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`:
- Around line 234-239: The handler currently silently ignores unknown
payload.type values; before returning 200 in the else branch that computes const
kind = eventType ? emailEventKindFromType(eventType) : null, add a warning/error
capture so unknown event kinds are visible: call captureError (or at minimum
console.warn) including the raw payload.type and payload body when
emailEventKindFromType returns null (or eventType is falsy) so operators see
when an unrecognized or new Resend event arrives; update the branch around
emailEventKindFromType and processEmailDeliveryEvent to log/capture and then
continue returning 200.
- Around line 81-98: In processEmailDeliveryEvent, when kind === "complained"
(currently only setting markedAsSpamAt), also record a suppression/unsubscribe
signal: call or implement a suppression helper (e.g.,
addToSuppressionList(email) or upsertSuppressionRecord) and invoke the
notification-preferences/unsubscribe flow (e.g.,
unsubscribeRecipientFromNotifications(recipientEmail, { source:
"resend.complained" })) for each recipient before returning; ensure the
suppression call uses the same recipient(s) derived from rawTo and include the
payload/context for auditing so complaints both set markedAsSpamAt and prevent
further sends.
In `@apps/backend/src/lib/email-queue-step.tsx`:
- Around line 733-745: Extract the hardcoded "smtp.resend.com" into a single
exported symbol and reuse it from both places; e.g., add and export
RESEND_SMTP_HOST (or a small helper providerCanDeliverInfo(emailConfig)) next to
getEmailConfig in lib/emails.tsx, then replace the literal in
email-queue-step.tsx where providerCanDeliverInfo is computed and in the
managed-email branch in lib/emails.tsx so both files import and use the shared
constant/helper to determine canHaveDeliveryInfo.
🪄 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: f02e5ca4-c2cd-4315-9ab3-675ab19736b5
📒 Files selected for processing (2)
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxapps/backend/src/lib/email-queue-step.tsx
| type ResendWebhookPayload = { | ||
| type?: string, | ||
| created_at?: string, | ||
| data?: { | ||
| // domain.* fields | ||
| id?: string, | ||
| status?: string, | ||
| error?: string, | ||
| // email.* fields | ||
| email_id?: string, | ||
| to?: string[] | string, | ||
| created_at?: string, | ||
| bounce?: { | ||
| message?: string, | ||
| subType?: string, | ||
| type?: string, | ||
| }, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Parse the webhook body through a yup schema instead of casting.
body is declared as yupMixed().optional() and the parsed payload is then coerced with JSON.parse(...) as ResendWebhookPayload, so every downstream field access (payload.type, payload.data?.to, payload.data?.created_at, …) is unchecked at runtime. A malformed/forged body that still passes Svix signature verification (e.g. a misconfigured Resend account sending a different event shape) will reach the SQL update path with arbitrary-typed values.
Since the coding guidelines disallow bypassing the type system via as, prefer defining a yupObject for ResendWebhookPayload (or at least data.to, data.created_at, data.email_id) and validating after JSON.parse — then the returned type is inferred, no cast needed.
As per coding guidelines: "Do NOT use as/any/type casts or anything else like that to bypass the type system. Most of the time a place where you would use type casts is not one where you actually need them".
Also applies to: 214-214
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`
around lines 35 - 53, The webhook parsing currently JSON.parse(...) as
ResendWebhookPayload after declaring body as yupMixed(), which bypasses runtime
checks; replace this by defining a yup.object() schema for ResendWebhookPayload
(at least require/shape data.to, data.created_at, data.email_id) and after
JSON.parse run schema.validateSync/validate (or async validate) to
coerce/validate the payload instead of using an `as` cast; update the code paths
that read payload.type, payload.data?.to, payload.data?.created_at to use the
validated result and remove the type cast so downstream accesses are type-safe
and validated.
| const windowStart = new Date(eventAt.getTime() - EVENT_MATCH_WINDOW_HOURS * 60 * 60 * 1000); | ||
|
|
||
| for (const recipient of recipients) { | ||
| const normalizedRecipient = recipient.trim().toLowerCase(); | ||
| if (normalizedRecipient.length === 0) continue; | ||
|
|
||
| // Find the single most recent outbox row that matches this recipient and was sent within | ||
| // the window. Match against either: | ||
| // - `to->emails` array (custom-emails / user-custom-emails with explicit emails), or | ||
| // - the user's primary email contact channel (user-primary-email, | ||
| // or user-custom-emails falling back to primary). | ||
| // We CTE-select the single best candidate then conditionally UPDATE it. | ||
| const updated = await globalPrismaClient.$queryRaw<{ id: string, tenancyId: string }[]>(Prisma.sql` | ||
| WITH candidate AS ( | ||
| SELECT o."tenancyId", o."id" | ||
| FROM "EmailOutbox" o | ||
| WHERE o."canHaveDeliveryInfo" = TRUE | ||
| AND o."finishedSendingAt" IS NOT NULL | ||
| AND o."finishedSendingAt" >= ${windowStart} | ||
| AND ${terminalGuard} | ||
| AND ${selfGuard} | ||
| AND ( | ||
| ( | ||
| o."to"->>'type' IN ('custom-emails', 'user-custom-emails') | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM jsonb_array_elements_text(COALESCE(o."to"->'emails', '[]'::jsonb)) AS e(value) | ||
| WHERE LOWER(e.value) = ${normalizedRecipient} | ||
| ) | ||
| ) | ||
| OR ( | ||
| o."to"->>'type' IN ('user-primary-email', 'user-custom-emails') | ||
| AND EXISTS ( | ||
| SELECT 1 FROM "ContactChannel" cc | ||
| WHERE cc."tenancyId" = o."tenancyId" | ||
| AND cc."projectUserId" = (o."to"->>'userId')::uuid | ||
| AND cc."type" = 'EMAIL' | ||
| AND LOWER(cc."value") = ${normalizedRecipient} | ||
| ) | ||
| ) | ||
| ) | ||
| ORDER BY o."finishedSendingAt" DESC | ||
| LIMIT 1 | ||
| ) | ||
| UPDATE "EmailOutbox" e | ||
| SET ${updateColumn} = ${eventAt}, | ||
| "shouldUpdateSequenceId" = TRUE | ||
| FROM candidate | ||
| WHERE e."tenancyId" = candidate."tenancyId" | ||
| AND e."id" = candidate."id" | ||
| AND ${terminalGuard} | ||
| AND ${selfGuard} | ||
| RETURNING e."id", e."tenancyId"; | ||
| `); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check whether EmailOutbox already has any provider-message-id column and whether
# lowLevelSendEmailDirectWithoutRetries returns the Resend message id today.
rg -nP --type=prisma -C2 'model\s+EmailOutbox\b'
fd -e prisma | xargs rg -nP -C1 'providerMessageId|resendMessageId|messageId'
rg -nP --type=ts -C3 'lowLevelSendEmailDirectWithoutRetries\b'
rg -nP --type=ts -C3 '\bemail_id\b|data\.email_id'Repository: stack-auth/stack-auth
Length of output: 5662
🏁 Script executed:
#!/bin/bash
# Find and inspect the EmailOutbox model definition
find . -type f -name "*.ts" -o -name "*.tsx" | xargs rg -l "model\s+EmailOutbox\b|interface\s+EmailOutbox\b" | head -5Repository: stack-auth/stack-auth
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Inspect the full return type and implementation of lowLevelSendEmailDirectWithoutRetries
rg -nA 30 'export async function lowLevelSendEmailDirectWithoutRetries' apps/backend/src/lib/emails-low-level.tsxRepository: stack-auth/stack-auth
Length of output: 879
🏁 Script executed:
#!/bin/bash
# Check if EmailOutbox update in webhook uses Resend's email_id or message_id
rg -nB 5 -A 15 'UPDATE.*EmailOutbox' apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxRepository: stack-auth/stack-auth
Length of output: 887
🏁 Script executed:
#!/bin/bash
# Search for any Prisma schema files
fd -e prisma . --type fRepository: stack-auth/stack-auth
Length of output: 98
🏁 Script executed:
#!/bin/bash
# Inspect the EmailOutbox model in the Prisma schema
rg -nA 50 'model EmailOutbox' apps/backend/prisma/schema.prismaRepository: stack-auth/stack-auth
Length of output: 7404
🏁 Script executed:
#!/bin/bash
# Check the _lowLevelSendEmailWithoutRetries to see if it returns Resend email_id
rg -nB 5 -A 50 'async function _lowLevelSendEmailWithoutRetries' apps/backend/src/lib/emails-low-level.tsxRepository: stack-auth/stack-auth
Length of output: 2076
🏁 Script executed:
#!/bin/bash
# Check the full webhook route file to understand the context around line 115-168
sed -n '1,50p' apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxRepository: stack-auth/stack-auth
Length of output: 2057
🏁 Script executed:
#!/bin/bash
# Check if there's a Resend service integration - search for 'resend' references in the codebase
rg -n 'resend.*send.*email|email.*resend' --type=ts --type=tsx apps/backend/src/lib/ | head -20Repository: stack-auth/stack-auth
Length of output: 92
🏁 Script executed:
#!/bin/bash
# Look for all fields in EmailOutbox model in the schema - check if there's any provider/message ID
sed -n '925,1070p' apps/backend/prisma/schema.prisma | grep -E '^\s+(.*Id|.*MessageId|provider|resend)'Repository: stack-auth/stack-auth
Length of output: 2086
🏁 Script executed:
#!/antml:
# Check the broader context of the webhook - see where the emailConfig comes from and if it can be 'resend'
sed -n '1,100p' apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxRepository: stack-auth/stack-auth
Length of output: 3838
🏁 Script executed:
#!/bin/bash
# Search for Resend API calls - check if emails are actually sent via Resend
rg -n 'new Resend|from.*resend|sendEmail.*resend' --type=ts --type=tsx apps/backend/src/Repository: stack-auth/stack-auth
Length of output: 92
🏁 Script executed:
#!/bin/bash
# Search for Resend service integration - look for 'resend' mentions in the codebase
rg -n 'resend' --type=ts apps/backend/src/lib/emails-low-level.tsx | head -30Repository: stack-auth/stack-auth
Length of output: 129
🏁 Script executed:
#!/bin/bash
# Look at the full webhook processing to see where emailConfig.type is determined
sed -n '100,200p' apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxRepository: stack-auth/stack-auth
Length of output: 4236
🏁 Script executed:
#!/bin/bash
# Check where lowLevelSendEmailDirectWithoutRetries is called and if the result is ever used
rg -n 'lowLevelSendEmailDirectWithoutRetries' apps/backend/src/lib/email-queue-step.tsx -A 20 | head -50Repository: stack-auth/stack-auth
Length of output: 1900
🏁 Script executed:
#!/bin/bash
# Check if there are any migrations or other fields that might store the email_id
rg -n 'email_id|messageId|providerMessageId|resendId' apps/backend/prisma/schema.prismaRepository: stack-auth/stack-auth
Length of output: 47
🏁 Script executed:
#!/antml:
# Check if the result from lowLevelSendEmailDirectWithoutRetries is actually used to update EmailOutbox with the email_id
sed -n '644,700p' apps/backend/src/lib/email-queue-step.tsxRepository: stack-auth/stack-auth
Length of output: 2635
🏁 Script executed:
#!/bin/bash
# Search for any alternate webhook handling or fallback that might use email_id
rg -n 'email_id|emailId|messageId' apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsxRepository: stack-auth/stack-auth
Length of output: 135
🏁 Script executed:
#!/bin/bash
# Check if the emailConfig type is actually 'resend' and how it's determined
rg -n 'emailConfig.*resend|type.*resend' apps/backend/src/lib/email-queue-step.tsx -B 3 -A 3Repository: stack-auth/stack-auth
Length of output: 488
🏁 Script executed:
#!/bin/bash
# Look for where emailConfig is set and if it's actually Resend
sed -n '600,650p' apps/backend/src/lib/email-queue-step.tsxRepository: stack-auth/stack-auth
Length of output: 2366
🏁 Script executed:
#!/antml:
# Check the full lowLevelSendEmailDirectWithoutRetries and especially the Resend integration part
sed -n '190,250p' apps/backend/src/lib/emails-low-level.tsxRepository: stack-auth/stack-auth
Length of output: 2322
Recipient + time-window matching can misattribute webhook events when the same recipient receives multiple emails.
Resend provides data.email_id on every email.* event (already captured here for error logging at line 176), which is the authoritative key for matching back to the sent message. The current strategy — selecting the latest EmailOutbox for a given recipient within a 96-hour window via ORDER BY "finishedSendingAt" DESC LIMIT 1 — will attribute webhooks to the wrong row whenever two or more emails to the same recipient are in flight (common for transactional retries, password resets, magic links). It can also silently drop a legitimate bounced event for an older send once a newer email to the same recipient has been marked as deliveredAt (the terminalGuard blocks the older row).
Consider persisting Resend's email_id on the EmailOutbox row when lowLevelSendEmailDirectWithoutRetries returns (via a new providerMessageId column), and matching on that as the primary key here, with the recipient/time-window only as a fallback. That would eliminate the 96-hour heuristic and the resend-webhook-email-event-no-match errors for events arriving for older rows.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`
around lines 115 - 168, The webhook matching should prioritize Resend's
authoritative data.email_id instead of only using recipient+time heuristics: add
a new providerMessageId column on EmailOutbox and persist Resend's email_id when
lowLevelSendEmailDirectWithoutRetries returns (store it alongside the outbox row
in the send path), then modify the matching in the route.tsx handler to first
attempt UPDATE ... WHERE e."providerMessageId" = ${data.email_id} (with existing
terminalGuard/selfGuard) and only if that returns no rows fall back to the
current recipient/time-window query (using normalizedRecipient, windowStart,
terminalGuard, selfGuard, updateColumn, eventAt) so webhooks reliably map to the
exact sent message.
| const updated = await globalPrismaClient.$queryRaw<{ id: string, tenancyId: string }[]>(Prisma.sql` | ||
| WITH candidate AS ( | ||
| SELECT o."tenancyId", o."id" | ||
| FROM "EmailOutbox" o | ||
| WHERE o."canHaveDeliveryInfo" = TRUE | ||
| AND o."finishedSendingAt" IS NOT NULL | ||
| AND o."finishedSendingAt" >= ${windowStart} | ||
| AND ${terminalGuard} | ||
| AND ${selfGuard} | ||
| AND ( | ||
| ( | ||
| o."to"->>'type' IN ('custom-emails', 'user-custom-emails') | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM jsonb_array_elements_text(COALESCE(o."to"->'emails', '[]'::jsonb)) AS e(value) | ||
| WHERE LOWER(e.value) = ${normalizedRecipient} | ||
| ) | ||
| ) | ||
| OR ( | ||
| o."to"->>'type' IN ('user-primary-email', 'user-custom-emails') | ||
| AND EXISTS ( | ||
| SELECT 1 FROM "ContactChannel" cc | ||
| WHERE cc."tenancyId" = o."tenancyId" | ||
| AND cc."projectUserId" = (o."to"->>'userId')::uuid | ||
| AND cc."type" = 'EMAIL' | ||
| AND LOWER(cc."value") = ${normalizedRecipient} | ||
| ) | ||
| ) | ||
| ) | ||
| ORDER BY o."finishedSendingAt" DESC | ||
| LIMIT 1 | ||
| ) | ||
| UPDATE "EmailOutbox" e | ||
| SET ${updateColumn} = ${eventAt}, | ||
| "shouldUpdateSequenceId" = TRUE | ||
| FROM candidate | ||
| WHERE e."tenancyId" = candidate."tenancyId" | ||
| AND e."id" = candidate."id" | ||
| AND ${terminalGuard} | ||
| AND ${selfGuard} | ||
| RETURNING e."id", e."tenancyId"; | ||
| `); |
There was a problem hiding this comment.
Also bound finishedSendingAt by the event time to prevent matching rows sent after the event.
The candidate CTE only enforces "finishedSendingAt" >= ${windowStart}. Combined with ORDER BY "finishedSendingAt" DESC, a delayed webhook processed after a newer email to the same recipient has already been sent will preferentially match the newer row. Adding an upper bound keeps the match causal.
🔧 Proposed fix
WHERE o."canHaveDeliveryInfo" = TRUE
AND o."finishedSendingAt" IS NOT NULL
AND o."finishedSendingAt" >= ${windowStart}
+ AND o."finishedSendingAt" <= ${eventAt}
AND ${terminalGuard}
AND ${selfGuard}Note: this partially overlaps with the broader email_id-based matching recommendation; if you adopt that, this becomes unnecessary.
📝 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.
| const updated = await globalPrismaClient.$queryRaw<{ id: string, tenancyId: string }[]>(Prisma.sql` | |
| WITH candidate AS ( | |
| SELECT o."tenancyId", o."id" | |
| FROM "EmailOutbox" o | |
| WHERE o."canHaveDeliveryInfo" = TRUE | |
| AND o."finishedSendingAt" IS NOT NULL | |
| AND o."finishedSendingAt" >= ${windowStart} | |
| AND ${terminalGuard} | |
| AND ${selfGuard} | |
| AND ( | |
| ( | |
| o."to"->>'type' IN ('custom-emails', 'user-custom-emails') | |
| AND EXISTS ( | |
| SELECT 1 | |
| FROM jsonb_array_elements_text(COALESCE(o."to"->'emails', '[]'::jsonb)) AS e(value) | |
| WHERE LOWER(e.value) = ${normalizedRecipient} | |
| ) | |
| ) | |
| OR ( | |
| o."to"->>'type' IN ('user-primary-email', 'user-custom-emails') | |
| AND EXISTS ( | |
| SELECT 1 FROM "ContactChannel" cc | |
| WHERE cc."tenancyId" = o."tenancyId" | |
| AND cc."projectUserId" = (o."to"->>'userId')::uuid | |
| AND cc."type" = 'EMAIL' | |
| AND LOWER(cc."value") = ${normalizedRecipient} | |
| ) | |
| ) | |
| ) | |
| ORDER BY o."finishedSendingAt" DESC | |
| LIMIT 1 | |
| ) | |
| UPDATE "EmailOutbox" e | |
| SET ${updateColumn} = ${eventAt}, | |
| "shouldUpdateSequenceId" = TRUE | |
| FROM candidate | |
| WHERE e."tenancyId" = candidate."tenancyId" | |
| AND e."id" = candidate."id" | |
| AND ${terminalGuard} | |
| AND ${selfGuard} | |
| RETURNING e."id", e."tenancyId"; | |
| `); | |
| const updated = await globalPrismaClient.$queryRaw<{ id: string, tenancyId: string }[]>(Prisma.sql` | |
| WITH candidate AS ( | |
| SELECT o."tenancyId", o."id" | |
| FROM "EmailOutbox" o | |
| WHERE o."canHaveDeliveryInfo" = TRUE | |
| AND o."finishedSendingAt" IS NOT NULL | |
| AND o."finishedSendingAt" >= ${windowStart} | |
| AND o."finishedSendingAt" <= ${eventAt} | |
| AND ${terminalGuard} | |
| AND ${selfGuard} | |
| AND ( | |
| ( | |
| o."to"->>'type' IN ('custom-emails', 'user-custom-emails') | |
| AND EXISTS ( | |
| SELECT 1 | |
| FROM jsonb_array_elements_text(COALESCE(o."to"->'emails', '[]'::jsonb)) AS e(value) | |
| WHERE LOWER(e.value) = ${normalizedRecipient} | |
| ) | |
| ) | |
| OR ( | |
| o."to"->>'type' IN ('user-primary-email', 'user-custom-emails') | |
| AND EXISTS ( | |
| SELECT 1 FROM "ContactChannel" cc | |
| WHERE cc."tenancyId" = o."tenancyId" | |
| AND cc."projectUserId" = (o."to"->>'userId')::uuid | |
| AND cc."type" = 'EMAIL' | |
| AND LOWER(cc."value") = ${normalizedRecipient} | |
| ) | |
| ) | |
| ) | |
| ORDER BY o."finishedSendingAt" DESC | |
| LIMIT 1 | |
| ) | |
| UPDATE "EmailOutbox" e | |
| SET ${updateColumn} = ${eventAt}, | |
| "shouldUpdateSequenceId" = TRUE | |
| FROM candidate | |
| WHERE e."tenancyId" = candidate."tenancyId" | |
| AND e."id" = candidate."id" | |
| AND ${terminalGuard} | |
| AND ${selfGuard} | |
| RETURNING e."id", e."tenancyId"; | |
| `); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx`
around lines 127 - 168, The candidate CTE can match emails sent after the
webhook event; add an upper bound on finishedSendingAt to ensure causality by
requiring o."finishedSendingAt" <= ${eventAt} (in addition to the existing >=
${windowStart}) inside the candidate WHERE clause. Update the SQL in the block
that assigns to updated (the candidate CTE used by the UPDATE of "EmailOutbox")
so the candidate selection uses both windowStart and eventAt while keeping the
existing terminalGuard and selfGuard logic.
Summary
email.*webhook events (delivered, delivery delayed, bounced, complained) and update the matchingEmailOutboxrow with the appropriate timestamp (recipient + time window match).domain.updatedbehavior for managed domain onboarding.canHaveDeliveryInfoto true when sending via Resend so outbox rows remain eligible for webhook updates.Testing
pnpm lint/pnpm typecheck(recommended)Made with Cursor
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes