Skip to content

feat(backend): Resend email delivery webhooks + EmailOutbox matching#1341

Open
mantrakp04 wants to merge 3 commits intodevfrom
feat/resend-email-delivery-webhooks
Open

feat(backend): Resend email delivery webhooks + EmailOutbox matching#1341
mantrakp04 wants to merge 3 commits intodevfrom
feat/resend-email-delivery-webhooks

Conversation

@mantrakp04
Copy link
Copy Markdown
Collaborator

@mantrakp04 mantrakp04 commented Apr 15, 2026

Summary

  • Handle Resend email.* webhook events (delivered, delivery delayed, bounced, complained) and update the matching EmailOutbox row with the appropriate timestamp (recipient + time window match).
  • Keep domain.updated behavior for managed domain onboarding.
  • Set canHaveDeliveryInfo to true when sending via Resend so outbox rows remain eligible for webhook updates.

Testing

  • pnpm lint / pnpm typecheck (recommended)
  • Exercise Resend webhook locally or in staging if available

Made with Cursor

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced email delivery tracking: processes and records delivery events (delivered, delayed, bounced, marked as spam) with recipient correlation and timestamp handling.
    • Detects when a provider can supply delivery info and marks outgoing messages accordingly.
  • Bug Fixes

    • More tolerant timestamp parsing and improved error capture for missing recipients and malformed events to reduce silent failures.

- 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
Copilot AI review requested due to automatic review settings April 15, 2026 21:38
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-backend Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-dashboard Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-demo Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-docs Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-preview-backend Ready Ready Preview, Comment Apr 20, 2026 7:43pm
stack-preview-dashboard Ready Ready Preview, Comment Apr 20, 2026 7:43pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Resend Webhook Event Handler
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
Replaced specific payload type with a generic ResendWebhookPayload; removed early-return for non-domain events and branched on payload.type. Added EmailEventKind mapping, processEmailDeliveryEvent, recipient normalization, 96-hour recipient-to-EmailOutbox matching, CTE-based raw SQL selection+conditional UPDATE, tolerant timestamp parsing, and additional captureError calls for parse/match failures.
Email Queue Delivery Capability
apps/backend/src/lib/email-queue-step.tsx
On successful send, compute providerCanDeliverInfo when context.emailConfig.host === "smtp.resend.com" and persist it to EmailOutbox.canHaveDeliveryInfo (instead of always false).

Sequence Diagram

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

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • N2D4

Poem

🐰 I hopped on logs at break of dawn,

chasing bytes where webhooks yawn.
Timestamps tucked and matches made,
Bounces caught, deliveries laid,
A rabbit's cheer — the mail parade! 📬🐇

🚥 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 accurately summarizes the main changes: adding Resend email delivery webhook handling and EmailOutbox row matching logic.
Description check ✅ Passed The description covers the key objectives (handling Resend webhooks, preserving domain.updated behavior, setting canHaveDeliveryInfo), testing guidance, and implementation details. All critical information is present and well-organized.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resend-email-delivery-webhooks

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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 15, 2026

Greptile Summary

This PR adds Resend email delivery webhook handling (email.delivered, email.delivery_delayed, email.bounced, email.complained) by matching incoming events to EmailOutbox rows via a recipient + time-window CTE, and sets canHaveDeliveryInfo = true on successful send when the SMTP host is exactly smtp.resend.com. Previously flagged issues (terminalGuard blocking spam complaints, race condition in the UPDATE WHERE clause, substring hostname check) have all been addressed.

Confidence Score: 5/5

Safe 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

Filename Overview
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx Adds email delivery event handling (delivered, delayed, bounced, complained) with CTE-based outbox matching; previously flagged race condition and terminalGuard bugs are fixed; remaining concerns are P2 (no-match captureError noise, cross-tenant matching ambiguity via recipient+time without email_id)
apps/backend/src/lib/email-queue-step.tsx Sets canHaveDeliveryInfo=true on successful send when SMTP host is exactly "smtp.resend.com"; exact-match check is safe and previously flagged substring issue is resolved

Sequence Diagram

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

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
This 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

Comment thread apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx Outdated
Comment thread apps/backend/src/lib/email-queue-step.tsx Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.canHaveDeliveryInfo as true when sending via Resend so rows remain eligible for delivery updates.
  • Extend the Resend webhook handler to process email.delivered, email.delivery_delayed, email.bounced, and email.complained events and update matching EmailOutbox timestamps.
  • Preserve existing domain.updated webhook 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.

Comment thread apps/backend/src/lib/email-queue-step.tsx
Comment thread apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx Outdated
Comment thread apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5341371 and a5d80b1.

📒 Files selected for processing (2)
  • apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
  • apps/backend/src/lib/email-queue-step.tsx

@mantrakp04 mantrakp04 requested a review from N2D4 April 20, 2026 18:13
- 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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx (2)

234-239: Silent drop for unknown type values.

When payload.type is set to something we don’t recognise (neither domain.updated nor a known email.*), we currently return 200 with no trace. One captureError (or at minimum a console.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.complained only writes markedAsSpamAt — consider surfacing a suppression signal too.

A complained event 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.complained into 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 like smtp.eu.resend.com), both sites must be edited in lockstep and the canHaveDeliveryInfo contract silently breaks on mismatch. A single exported constant (or a small providerCanDeliverInfo(emailConfig) helper co-located with getEmailConfig) 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_HOST exported from lib/emails.tsx and 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

📥 Commits

Reviewing files that changed from the base of the PR and between a5d80b1 and b7e6b4f.

📒 Files selected for processing (2)
  • apps/backend/src/app/api/latest/integrations/resend/webhooks/route.tsx
  • apps/backend/src/lib/email-queue-step.tsx

Comment on lines +35 to 53
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,
},
},
};
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.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +115 to +168
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";
`);
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.

⚠️ Potential issue | 🟠 Major

🧩 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 -5

Repository: 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.tsx

Repository: 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.tsx

Repository: stack-auth/stack-auth

Length of output: 887


🏁 Script executed:

#!/bin/bash
# Search for any Prisma schema files
fd -e prisma . --type f

Repository: 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.prisma

Repository: 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.tsx

Repository: 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.tsx

Repository: 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 -20

Repository: 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.tsx

Repository: 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 -30

Repository: 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.tsx

Repository: 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 -50

Repository: 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.prisma

Repository: 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.tsx

Repository: 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.tsx

Repository: 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 3

Repository: 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.tsx

Repository: 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.tsx

Repository: 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.

Comment on lines +127 to +168
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";
`);
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.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

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