Add optional Resend delivery event logging - #198
Conversation
Store Resend email lifecycle events idempotently by Svix ID and expose delivery latency and outcome metrics. Share the ten-minute OTP lifetime between Better Auth and the latency threshold so the two cannot drift. Refs #185 Co-authored-by: OpenAI Codex <noreply@openai.com>
Add a raw-body webhook endpoint that verifies Svix signatures before validating and persisting supported Resend email events. Retries are acknowledged idempotently and delayed-delivery events are surfaced in logs. Refs #185 Co-authored-by: OpenAI Codex <noreply@openai.com>
Document the webhook URL, event subscriptions, signing secret, metrics output, and the required live SMTP verification. Propagate RESEND_WEBHOOK_SECRET into generated auth-service environments and add release notes. Refs #185 Co-authored-by: OpenAI Codex <noreply@openai.com>
🦋 Changeset detectedLatest commit: d275b33 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 36 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughAdds an optional ChangesResend delivery webhooks
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Resend
participant AuthService
participant Svix
participant Logger
Resend->>AuthService: POST /webhooks/resend
AuthService->>Svix: Verify signed raw payload
Svix-->>AuthService: Return validated event
AuthService->>Logger: Log matching delivery event
AuthService-->>Resend: Return acknowledgement
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Coverage Report for CI Build 29748067755Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.8%) to 57.186%Details
Uncovered Changes
Coverage Regressions5 previously-covered lines in 2 files lost coverage.
Coverage Stats
💛 - Coveralls |
There was a problem hiding this comment.
Pull request overview
Adds first-class Resend delivery webhook ingestion to the auth-service so operators can measure OTP email delivery latency (sent→delivered) and distinguish SMTP handoff from downstream delivery delays. This extends the shared SQLite DB with persistent, idempotent event storage and exposes aggregated delivery/latency stats via the existing authenticated /metrics endpoint, with documentation and tests.
Changes:
- Introduce a signed
POST /webhooks/resendreceiver (Svix verification over raw body) and persist supported lifecycle events idempotently in SQLite. - Aggregate delivery outcomes and latency statistics (including “over OTP lifetime” fraction) into the existing
/metricsresponse asresendDelivery. - Add/ratchet tests, coverage thresholds, and operator documentation + env propagation for
RESEND_WEBHOOK_SECRET.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Ratchets coverage thresholds to the new floor. |
| scripts/setup.sh | Propagates RESEND_WEBHOOK_SECRET into per-package .env files when applicable. |
| pnpm-lock.yaml | Locks new svix dependency (and transitive deps). |
| packages/shared/src/types.ts | Adds shared OTP_LIFETIME_SECONDS constant to avoid drift. |
| packages/shared/src/index.ts | Re-exports new shared constant and Resend-related DB/metrics types. |
| packages/shared/src/db.ts | Adds resend_email_event table/migration, persistence helpers, and resendDelivery metrics aggregation. |
| packages/shared/src/tests/db-extended.test.ts | Adds DB-level tests for event ordering/deduplication and delivery metrics aggregation. |
| packages/auth-service/src/routes/resend-webhook.ts | New webhook route: raw-body ingestion, Svix signature verification, payload validation, DB persistence, and logging. |
| packages/auth-service/src/lib/auth-flow.ts | Updates comment to reference shared OTP lifetime constant. |
| packages/auth-service/src/index.ts | Mounts webhook router before body parsing; wires RESEND_WEBHOOK_SECRET into config. |
| packages/auth-service/src/context.ts | Extends config with optional resendWebhookSecret. |
| packages/auth-service/src/better-auth.ts | Uses shared OTP_LIFETIME_SECONDS for Better Auth OTP expiry. |
| packages/auth-service/src/tests/resend-webhook.test.ts | Adds HTTP-level integration tests for signature verification, idempotency, and payload filtering. |
| packages/auth-service/package.json | Adds svix dependency. |
| packages/auth-service/.env.example | Documents RESEND_WEBHOOK_SECRET usage for the auth service. |
| docs/design/testing-gaps.md | Updates coverage notes to reflect new route-level test coverage. |
| docs/configuration.md | Documents webhook setup steps and resulting /metrics fields. |
| .env.example | Documents RESEND_WEBHOOK_SECRET at the repo level. |
| .changeset/resend-delivery-webhooks.md | Adds operator-facing changeset for the new delivery metrics feature. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
packages/shared/src/__tests__/db-extended.test.ts (1)
283-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueImport
ResendEmailEventTypeinstead of re-declaring the union inline.The
eventTypeparameter re-lists the five literal event types instead of importingResendEmailEventTypefrom../db.js. If a new Resend event type is added later, this local copy can silently drift out of sync with the source of truth.♻️ Suggested fix
+import type { ResendEmailEventType } from '../db.js' ... function record( svixId: string, emailId: string, - eventType: - | 'email.sent' - | 'email.delivered' - | 'email.delivery_delayed' - | 'email.bounced' - | 'email.failed', + eventType: ResendEmailEventType, eventCreatedAt: number, ): boolean {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/shared/src/__tests__/db-extended.test.ts` around lines 283 - 303, Update the test helper function record to import and use ResendEmailEventType from ../db.js for its eventType parameter, removing the duplicated inline literal union and keeping the existing event values and behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/auth-service/src/index.ts`:
- Around line 51-56: In the webhook setup block before
createResendWebhookRouter, mount a dedicated rate limiter for /webhooks/resend
before the router handles requests. Configure it to allow legitimate Resend
retry bursts while limiting repeated raw-body parsing, signature verification,
and warning logs, without changing the global limiter or webhook verification
flow.
In `@packages/auth-service/src/routes/resend-webhook.ts`:
- Around line 125-127: Remove recipients, sender, and subject from the persisted
record constructed in the resend webhook route, retaining only fields required
by delivery metrics and latency queries such as email_id, event type, and
timestamps.
In `@packages/shared/src/db.ts`:
- Around line 224-243: The resend_email_event migration creates indefinitely
retained recipient data without cleanup. Add a TTL purge for resend_email_event,
using received_at or the established retention policy, and invoke it from the
existing database maintenance path near deleteAccountData or related cleanup
methods; preserve the table and indexes while ensuring expired rows are
periodically deleted.
---
Nitpick comments:
In `@packages/shared/src/__tests__/db-extended.test.ts`:
- Around line 283-303: Update the test helper function record to import and use
ResendEmailEventType from ../db.js for its eventType parameter, removing the
duplicated inline literal union and keeping the existing event values and
behavior unchanged.
🪄 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: c1e829c3-1ae1-432c-a951-0eb5563eabcc
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (18)
.changeset/resend-delivery-webhooks.md.env.exampledocs/configuration.mddocs/design/testing-gaps.mdpackages/auth-service/.env.examplepackages/auth-service/package.jsonpackages/auth-service/src/__tests__/resend-webhook.test.tspackages/auth-service/src/better-auth.tspackages/auth-service/src/context.tspackages/auth-service/src/index.tspackages/auth-service/src/lib/auth-flow.tspackages/auth-service/src/routes/resend-webhook.tspackages/shared/src/__tests__/db-extended.test.tspackages/shared/src/db.tspackages/shared/src/index.tspackages/shared/src/types.tsscripts/setup.shvitest.config.ts
|
🚅 Deployed to the ePDS-pr-198 environment in ePDS
|
Clarify that ePDS supports any SMTP provider and expose the Resend route and metrics only after an operator opts in with a webhook secret. Bound the optional integration with dedicated rate limiting, 30-day retention, and reduced persisted payload data. Refs #185 Co-authored-by: OpenAI Codex <noreply@openai.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (1)
packages/auth-service/src/index.ts:70
requestRateLimit()keys offreq.ip, buttrust proxyis configured after the Resend webhook rate limiter is mounted. In deployments behind a reverse proxy, this will rate-limit all webhook calls under the proxy’s IP (or otherwise ignore X-Forwarded-For), potentially throttling legitimate delivery events.
// Middleware
app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
app.use(cookieParser())
Remove the webhook database schema and in-process delivery metrics. The optional signed receiver now emits structured event logs with Svix IDs so external log analysis can deduplicate retries, order events, and calculate latency without ePDS retaining webhook data. Refs #185 Co-authored-by: OpenAI Codex <noreply@openai.com>
|
(comment generated by OpenAI Codex) Follow-up |
Ignore only missing temporary database directories during teardown instead of swallowing every filesystem error. Co-authored-by: OpenAI Codex <noreply@openai.com>
|
Given that our main Resend team only delivers to staging and production I guess we can tolerate this with suitable filters on both. |
Resend models recipients as an array, which made Railway display the single ePDS recipient as recipients[0]. Webhook subjects also include the sign-in code and would expose it in logs.\n\nNormalize the recipient to the existing email field and redact only OTP tokens matching the configured length and charset while preserving the rest of the subject.\n\nFollow-up to #185.\n\nCo-authored-by: OpenAI <noreply@openai.com>
Use concise digit character classes so SonarCloud accepts the OTP subject sanitizer added for #185.\n\nCo-authored-by: OpenAI <noreply@openai.com>
SonarCloud reports intentional localhost, Docker, and Railway-private HTTP URLs as clear-text protocol vulnerabilities. Mark those exact lines with justified NOSONAR annotations because the traffic never crosses the public network.\n\nCo-authored-by: OpenAI <noreply@openai.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/auth-service/src/__tests__/resend-webhook.test.ts (1)
50-104: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUse
supertestfor testing route handlers.The manual spinning up of an ephemeral Express server with
app.listen(0)and making a rawfetchrequest violates the coding guideline to cover route handlers viasupertestintegration tests. Usingsupertestremoves the need for manual server lifecycle management and port allocation boilerplate.Please remember to add
import request from 'supertest'at the top of the file.🛠️ Proposed refactor to use supertest
- const app = express() - app.use( - createResendWebhookRouter( - WEBHOOK_SECRET, - 'login@example.org', - options.otpLength ?? 6, - options.otpCharset ?? 'numeric', - ), - ) - const server = app.listen(0) - - try { - server.unref() - const port = await new Promise<number>((resolve, reject) => { - server.once('error', reject) - server.once('listening', () => { - const address = server.address() - if (typeof address === 'object' && address) resolve(address.port) - else reject(new Error('Failed to resolve ephemeral port')) - }) - }) - - const payload = JSON.stringify(event) - const svixId = options.svixId ?? 'msg_test_123' - const timestamp = new Date() - const signature = - options.validSignature === false - ? 'v1,invalid' - : new Webhook(WEBHOOK_SECRET).sign(svixId, timestamp, payload) - - const headers: Record<string, string> = { - 'Content-Type': 'application/json', - } - if (options.includeHeaders !== false) { - headers['svix-id'] = svixId - headers['svix-timestamp'] = String(Math.floor(timestamp.getTime() / 1000)) - headers['svix-signature'] = signature - } - - const response = await fetch(`http://127.0.0.1:${port}/webhooks/resend`, { - method: 'POST', - headers, - body: payload, - }) - return { - status: response.status, - json: (await response.json()) as Record<string, unknown>, - } - } finally { - await new Promise<void>((resolve) => { - server.close(() => { - resolve() - }) - }) - } + const app = express() + app.use( + createResendWebhookRouter( + WEBHOOK_SECRET, + 'login@example.org', + options.otpLength ?? 6, + options.otpCharset ?? 'numeric', + ), + ) + + const payload = JSON.stringify(event) + const svixId = options.svixId ?? 'msg_test_123' + const timestamp = new Date() + const signature = + options.validSignature === false + ? 'v1,invalid' + : new Webhook(WEBHOOK_SECRET).sign(svixId, timestamp, payload) + + const req = request(app) + .post('/webhooks/resend') + .set('Content-Type', 'application/json') + + if (options.includeHeaders !== false) { + req.set('svix-id', svixId) + req.set('svix-timestamp', String(Math.floor(timestamp.getTime() / 1000))) + req.set('svix-signature', signature) + } + + const response = await req.send(payload) + + return { + status: response.status, + json: response.body as Record<string, unknown>, + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/auth-service/src/__tests__/resend-webhook.test.ts` around lines 50 - 104, Refactor the test helper around createResendWebhookRouter to use supertest(request) against the Express app instead of app.listen, ephemeral port resolution, fetch, and manual server cleanup. Preserve the existing payload, headers, signature, and returned status/json behavior, and add the supertest import at the top of the test file.Source: Coding guidelines
packages/auth-service/src/routes/resend-webhook.ts (1)
119-126: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winMissing-header / non-buffer branch swallows the failure silently.
Unlike the signature-invalid and payload-invalid branches below (which both call
logger.warnbefore returning), this branch returns{ ok: false, error: 'Invalid webhook request' }with no logging at all. As per coding guidelines, "Never swallow errors silently; log at minimum debug level" — a misconfigured proxy stripping Svix headers, or a body-parser mismatch, would go completely unobserved here.🔧 Proposed fix
const headers = getSvixHeaders(req) if (!headers || !Buffer.isBuffer(req.body)) { + logger.warn( + { hasHeaders: !!headers, isBuffer: Buffer.isBuffer(req.body) }, + 'Rejected email webhook with missing headers or body', + ) return { ok: false, error: 'Invalid webhook request' } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/auth-service/src/routes/resend-webhook.ts` around lines 119 - 126, Add a warning log in the missing-header/non-buffer branch of verifyResendWebhook before returning the existing invalid-request result, matching the logging behavior of the signature-invalid and payload-invalid branches. Include enough context to distinguish missing Svix headers from an invalid request body.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@packages/auth-service/src/__tests__/resend-webhook.test.ts`:
- Around line 50-104: Refactor the test helper around createResendWebhookRouter
to use supertest(request) against the Express app instead of app.listen,
ephemeral port resolution, fetch, and manual server cleanup. Preserve the
existing payload, headers, signature, and returned status/json behavior, and add
the supertest import at the top of the test file.
In `@packages/auth-service/src/routes/resend-webhook.ts`:
- Around line 119-126: Add a warning log in the missing-header/non-buffer branch
of verifyResendWebhook before returning the existing invalid-request result,
matching the logging behavior of the signature-invalid and payload-invalid
branches. Include enough context to distinguish missing Svix headers from an
invalid request body.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1c08af21-1745-48d3-ad83-2a47c4a4227c
📒 Files selected for processing (4)
docs/configuration.mdpackages/auth-service/src/__tests__/resend-webhook.test.tspackages/auth-service/src/index.tspackages/auth-service/src/routes/resend-webhook.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/auth-service/src/index.ts
- docs/configuration.md
This comment has been minimized.
This comment has been minimized.
Operators using Resend open tracking could see delivery outcomes but not whether the message's tracking pixel was fetched.\n\nAccept email.opened webhooks, normalize them to opened, and document the required tracking opt-in and reliability caveats. The event uses the existing sender filter and sanitized provider-neutral log fields.\n\nFollow-up to #185.\n\nCo-authored-by: OpenAI <noreply@openai.com>
|



Summary
Add an explicitly optional Resend webhook integration for operators who already use Resend and want delivery diagnostics. ePDS remains compatible with any SMTP provider; when enabled, the signed receiver emits structured logs and does not persist webhook data.
Addresses #185.
Changes
POST /webhooks/resend, enabled only whenRESEND_WEBHOOK_SECRETis set.provider,eventId,eventType,occurredAt,messageId,recipients, andsubject; delayed-delivery events use warning level.SMTP_FROM, acknowledging other senders without logging their payloads.Testing
pnpm format:checkpnpm lintpnpm typecheckpnpm buildpnpm test— 1,054 tests passedpnpm test:coverage— 58.02% statements, 57.48% branches, 71.08% functions, 56.75% linesNotes
A live production SMTP-to-webhook test was not performed because it requires the production Resend dashboard, signing secret, and SMTP credentials. Resend does not explicitly document whether SMTP-submitted messages emit these webhooks, so Resend users must complete the documented test before relying on the logs.
Retries intentionally produce repeated log entries carrying the same provider-neutral
eventId; downstream log analysis should deduplicate on that field. ePDS instances sharing a Resend account must use distinctSMTP_FROMaddresses because events sent from the same address cannot be separated.Summary by CodeRabbit