Skip to content

Commit fd558aa

Browse files
authored
Merge pull request #198 from hypercerts-org/feat/resend-delivery-webhooks
Add optional Resend delivery event logging
2 parents c0b1eb1 + d275b33 commit fd558aa

16 files changed

Lines changed: 704 additions & 25 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'ePDS': minor
3+
---
4+
5+
Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.
6+
7+
**Affects:** Operators
8+
9+
**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https://<AUTH_HOSTNAME>/webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; optional Resend open tracking adds `opened` logs. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, and does not persist webhook data.

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ SMTP_PASS=
214214
SMTP_FROM=noreply@pds.example
215215
SMTP_FROM_NAME=ePDS
216216

217+
# Resend users only (optional): email webhook signing secret. ePDS supports
218+
# any SMTP provider and does not require Resend. To opt into Resend email event
219+
# logs, configure https://auth.pds.example/webhooks/resend and set:
220+
# RESEND_WEBHOOK_SECRET=whsec_...
221+
# Account-wide Resend events are filtered to the exact SMTP_FROM address.
222+
217223
DB_LOCATION=/data/epds.sqlite
218224

219225
# ============================================================================

docs/configuration.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,57 @@ buttons appear on the login page.
198198
| `POSTMARK_SERVER_TOKEN` | Postmark server token |
199199
| `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. |
200200

201+
#### Optional Resend email event logs
202+
203+
ePDS does **not** require Resend: operators can use any SMTP provider. This
204+
provider-specific integration is only for operators who already send through
205+
Resend and choose to log its email webhooks. Without
206+
`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted.
207+
208+
| Variable | Description |
209+
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
210+
| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. |
211+
212+
To opt into email-event logging for email sent through Resend:
213+
214+
1. In the Resend dashboard, register `https://<AUTH_HOSTNAME>/webhooks/resend`.
215+
2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`,
216+
`email.opened`, `email.bounced`, and `email.failed`.
217+
3. To receive `email.opened`, enable Resend open tracking for the sending domain
218+
and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS
219+
does not alter email HTML. Without tracking, the other events still work.
220+
4. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy
221+
the auth service.
222+
5. Send a test sign-in code through the production SMTP configuration and
223+
confirm that `email.sent` and `email.delivered` reach the receiver. If open
224+
tracking is enabled, open the message and confirm `email.opened` also arrives.
225+
Resend's documentation does not explicitly guarantee that SMTP-submitted
226+
email emits webhooks, so verify this before relying on the logs.
227+
228+
Resend webhooks cover the whole Resend account rather than one sending domain.
229+
The receiver parses each event's `data.from` address and logs it only when it
230+
exactly matches this ePDS instance's `SMTP_FROM`; events for other senders are
231+
acknowledged without logging their payload. ePDS instances sharing a Resend
232+
account must therefore use distinct `SMTP_FROM` addresses. Events cannot be
233+
separated when multiple instances use the same sender address.
234+
235+
The receiver verifies the Svix signature against the raw request body and emits
236+
a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`,
237+
`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced
238+
with `[REDACTED]` to prevent it from reaching logs while preserving the rest of
239+
the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`,
240+
`opened`, `bounced`, or `failed`. Normal events use `info`; `delayed` uses
241+
`warn`. An `opened` event means the tracking pixel was fetched, which can be
242+
caused or suppressed by mail-client privacy features and is not proof that the
243+
recipient read the message. No webhook data is persisted by ePDS. The
244+
provider-specific rate limit permits 300 webhook requests per minute per source
245+
IP.
246+
247+
Resend retries carry the same source event ID, logged as `eventId`, and events
248+
may arrive out of order. Log analysis should deduplicate by `eventId`, order
249+
each `messageId` by `occurredAt`, and subtract `sent` from `delivered` to
250+
calculate delivery latency.
251+
201252
### Database
202253

203254
| Variable | Description |

docs/design/testing-gaps.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pds-core callback, full OAuth flow).
1515
| `auth-service/lib/` | ~77% | `auto-provision.ts` at 0% (needs live PDS) |
1616
| `auth-service/middleware/` | ~91% | `rate-limit.ts` timer cleanup at 82% |
1717
| `auth-service/email/` | ~71% | Template conditional branches partially covered |
18-
| `auth-service/routes/` | 0% | All seven route files — see below |
18+
| `auth-service/routes/` | ~32% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps |
1919
| `auth-service/better-auth.ts` | 0% | better-auth wiring — see below |
2020
| `auth-service/context.ts` | 0% | Minimal glue class |
2121
| `auth-service/index.ts` | 0% | Express app assembly + `main()` |
@@ -57,10 +57,13 @@ pds-core callback, full OAuth flow).
5757
concerns that should be covered by end-to-end tests (see
5858
`docs/design/e2e-testing.md`).
5959

60-
### 2. `auth-service/src/routes/` (0% — 7 route files, ~2300 lines total)
60+
### 2. `auth-service/src/routes/` (low coverage)
6161

62-
**Files:** `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`,
63-
`account-settings.ts`, `choose-handle.ts`, `complete.ts`
62+
The signed `resend-webhook.ts` receiver has HTTP-level integration coverage
63+
for valid signatures, rejected signatures, supported event filtering,
64+
structured logging, and retry correlation. The browser/OAuth route files remain the
65+
main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`,
66+
`account-settings.ts`, `choose-handle.ts`, and `complete.ts`.
6467

6568
**Why they're hard:**
6669

@@ -78,6 +81,9 @@ pds-core callback, full OAuth flow).
7881

7982
**What can be tested:**
8083

84+
- Provider webhook routes can be tested through an ephemeral Express server
85+
with locally signed payloads, as demonstrated by
86+
`resend-webhook.test.ts`.
8187
- The DB-level logic used by these routes is already well-covered via
8288
`consent.test.ts` (auth_flow operations, client login tracking,
8389
signCallback round-trip).

packages/auth-service/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ SMTP_PASS=
138138
SMTP_FROM=noreply@pds.example
139139
SMTP_FROM_NAME=ePDS
140140

141+
# Resend users only (optional). ePDS supports any SMTP provider and does not
142+
# require Resend. To opt into Resend email event logs, register
143+
# https://<AUTH_HOSTNAME>/webhooks/resend for email.sent, email.delivered,
144+
# email.delivery_delayed, email.opened, email.bounced, and email.failed, then set:
145+
# RESEND_WEBHOOK_SECRET=whsec_...
146+
# Account-wide Resend events are filtered to the exact SMTP_FROM address.
147+
141148
# Database path (separate from PDS account.sqlite)
142149
DB_LOCATION=/data/epds.sqlite
143150

packages/auth-service/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"cookie-parser": "^1.4.6",
1717
"dotenv": "^16.3.1",
1818
"express": "^4.18.2",
19-
"nodemailer": "^6.9.8"
19+
"nodemailer": "^6.9.8",
20+
"svix": "^1.96.1"
2021
},
2122
"devDependencies": {
2223
"@types/better-sqlite3": "^7.6.8",

packages/auth-service/src/__tests__/rate-limit.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ describe('requestRateLimit', () => {
9898
expect(next2).toBe(true)
9999
})
100100

101+
it('keeps independently configured limiter namespaces separate', () => {
102+
const ip = `namespace-${Date.now()}-${randomUUID()}`
103+
const globalLimiter = requestRateLimit({
104+
windowMs: 60_000,
105+
maxRequests: 1,
106+
})
107+
const webhookLimiter = requestRateLimit({
108+
windowMs: 60_000,
109+
maxRequests: 1,
110+
keyPrefix: 'resend-webhook',
111+
})
112+
113+
for (const limiter of [globalLimiter, webhookLimiter]) {
114+
const req = makeReq(ip)
115+
const res = makeRes()
116+
let nextCalled = false
117+
limiter(req as never, res as never, () => {
118+
nextCalled = true
119+
})
120+
expect(nextCalled).toBe(true)
121+
}
122+
})
123+
101124
it('uses req.ip when available', () => {
102125
const limiter = requestRateLimit({ windowMs: 60000, maxRequests: 10 })
103126
const req = {

0 commit comments

Comments
 (0)