Skip to content

Commit aec72ac

Browse files
authored
Merge pull request #206 from hypercerts-org/feat/resend-webhook-additional-events
auth: expand Resend email event handling
2 parents fd558aa + c060319 commit aec72ac

7 files changed

Lines changed: 100 additions & 24 deletions

File tree

.changeset/resend-delivery-webhooks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ Optional email-event logging for operators who choose the third-party Resend ser
66

77
**Affects:** Operators
88

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.
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`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data.

docs/configuration.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ To opt into email-event logging for email sent through Resend:
213213

214214
1. In the Resend dashboard, register `https://<AUTH_HOSTNAME>/webhooks/resend`.
215215
2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`,
216-
`email.opened`, `email.bounced`, and `email.failed`.
216+
`email.opened`, `email.bounced`, `email.failed`, `email.complained`,
217+
`email.suppressed`, and `email.scheduled`.
217218
3. To receive `email.opened`, enable Resend open tracking for the sending domain
218219
and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS
219220
does not alter email HTML. Without tracking, the other events still work.
@@ -237,10 +238,14 @@ a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`,
237238
`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced
238239
with `[REDACTED]` to prevent it from reaching logs while preserving the rest of
239240
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
241+
`opened`, `bounced`, `failed`, `complained`, `suppressed`, or `scheduled`.
242+
`Delayed`, `complained`, and `suppressed` events use `warn`; the rest use `info`.
243+
An `opened` event means the tracking pixel was fetched, which can be caused or
244+
suppressed by mail-client privacy features and is not proof that the recipient
245+
read the message. Signed `email.clicked` and inbound `email.received` events are
246+
acknowledged without logging their payloads, preventing retries if the Resend
247+
endpoint is accidentally subscribed to them. No webhook data is persisted by
248+
ePDS. The
244249
provider-specific rate limit permits 300 webhook requests per minute per source
245250
IP.
246251

docs/design/testing-gaps.md

Lines changed: 4 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/` | ~32% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps |
18+
| `auth-service/routes/` | ~35% | 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()` |
@@ -60,9 +60,9 @@ pds-core callback, full OAuth flow).
6060
### 2. `auth-service/src/routes/` (low coverage)
6161

6262
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`,
63+
for valid signatures, rejected signatures, logged and ignored event filtering,
64+
structured logging, and retry correlation. The browser/OAuth route files remain
65+
this area's main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`,
6666
`account-settings.ts`, `choose-handle.ts`, and `complete.ts`.
6767

6868
**Why they're hard:**

packages/auth-service/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ SMTP_FROM_NAME=ePDS
141141
# Resend users only (optional). ePDS supports any SMTP provider and does not
142142
# require Resend. To opt into Resend email event logs, register
143143
# https://<AUTH_HOSTNAME>/webhooks/resend for email.sent, email.delivered,
144-
# email.delivery_delayed, email.opened, email.bounced, and email.failed, then set:
144+
# email.delivery_delayed, email.opened, email.bounced, email.failed,
145+
# email.complained, email.suppressed, and email.scheduled, then set:
145146
# RESEND_WEBHOOK_SECRET=whsec_...
146147
# Account-wide Resend events are filtered to the exact SMTP_FROM address.
147148

packages/auth-service/src/__tests__/resend-webhook.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,17 @@ describe('Resend webhook receiver', () => {
123123
)
124124
})
125125

126-
it('normalizes open-tracking events', async () => {
127-
const result = await postWebhook(makeEvent('email.opened'))
126+
it.each([
127+
['email.opened', 'opened'],
128+
['email.scheduled', 'scheduled'],
129+
])('normalizes %s events as %s', async (resendType, normalizedType) => {
130+
const result = await postWebhook(makeEvent(resendType))
128131

129132
expect(result).toEqual({ status: 200, json: { received: true } })
130133
expect(logInfo).toHaveBeenCalledWith(
131134
expect.objectContaining({
132135
provider: 'resend',
133-
eventType: 'opened',
136+
eventType: normalizedType,
134137
messageId: 'resend-email-123',
135138
email: 'person@example.com',
136139
}),
@@ -205,14 +208,18 @@ describe('Resend webhook receiver', () => {
205208
)
206209
})
207210

208-
it('logs delivery delays at warning level', async () => {
209-
await postWebhook(makeEvent('email.delivery_delayed'))
211+
it.each([
212+
['email.delivery_delayed', 'delayed'],
213+
['email.complained', 'complained'],
214+
['email.suppressed', 'suppressed'],
215+
])('logs %s as %s at warning level', async (resendType, normalizedType) => {
216+
await postWebhook(makeEvent(resendType))
210217

211218
expect(logWarn).toHaveBeenCalledWith(
212219
expect.objectContaining({
213220
provider: 'resend',
214221
eventId: 'msg_test_123',
215-
eventType: 'delayed',
222+
eventType: normalizedType,
216223
messageId: 'resend-email-123',
217224
}),
218225
'Received email event',
@@ -237,8 +244,30 @@ describe('Resend webhook receiver', () => {
237244
expect(logInfo).not.toHaveBeenCalled()
238245
})
239246

240-
it('rejects signed event types that are not used for email logs', async () => {
241-
const result = await postWebhook(makeEvent('email.received'))
247+
it.each(['email.clicked', 'email.received'])(
248+
'acknowledges ignored %s events without logging their payload',
249+
async (eventType) => {
250+
const result = await postWebhook(makeEvent(eventType))
251+
252+
expect(result).toEqual({
253+
status: 200,
254+
json: { received: true, ignored: true },
255+
})
256+
expect(logInfo).not.toHaveBeenCalled()
257+
expect(logWarn).not.toHaveBeenCalled()
258+
expect(logDebug).toHaveBeenCalledWith(
259+
{
260+
provider: 'resend',
261+
eventId: 'msg_test_123',
262+
eventType: eventType.slice('email.'.length),
263+
},
264+
'Ignored unsupported email webhook event',
265+
)
266+
},
267+
)
268+
269+
it('rejects unknown signed event types', async () => {
270+
const result = await postWebhook(makeEvent('email.unknown'))
242271

243272
expect(result.status).toBe(400)
244273
expect(result.json).toEqual({ error: 'Invalid webhook payload' })

packages/auth-service/src/routes/resend-webhook.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,31 @@ const logger = createLogger('auth:email-webhook')
1717

1818
export const RESEND_WEBHOOK_PATH = '/webhooks/resend'
1919

20-
const RESEND_EVENT_TYPES = [
20+
const LOGGED_RESEND_EVENT_TYPES = [
2121
'email.sent',
2222
'email.delivered',
2323
'email.delivery_delayed',
2424
'email.opened',
2525
'email.bounced',
2626
'email.failed',
27+
'email.complained',
28+
'email.suppressed',
29+
'email.scheduled',
2730
] as const
2831

32+
const IGNORED_RESEND_EVENT_TYPES = ['email.clicked', 'email.received'] as const
33+
const RESEND_EVENT_TYPES = [
34+
...LOGGED_RESEND_EVENT_TYPES,
35+
...IGNORED_RESEND_EVENT_TYPES,
36+
] as const
37+
38+
const WARNING_RESEND_EVENT_TYPES = [
39+
'email.delivery_delayed',
40+
'email.complained',
41+
'email.suppressed',
42+
] as const
43+
44+
type LoggedResendEventType = (typeof LOGGED_RESEND_EVENT_TYPES)[number]
2945
type ResendEventType = (typeof RESEND_EVENT_TYPES)[number]
3046
type EmailEventType =
3147
| 'sent'
@@ -34,15 +50,21 @@ type EmailEventType =
3450
| 'opened'
3551
| 'bounced'
3652
| 'failed'
53+
| 'complained'
54+
| 'suppressed'
55+
| 'scheduled'
3756
type OtpCharset = 'numeric' | 'alphanumeric'
3857

39-
const NORMALIZED_EVENT_TYPES: Record<ResendEventType, EmailEventType> = {
58+
const NORMALIZED_EVENT_TYPES: Record<LoggedResendEventType, EmailEventType> = {
4059
'email.sent': 'sent',
4160
'email.delivered': 'delivered',
4261
'email.delivery_delayed': 'delayed',
4362
'email.opened': 'opened',
4463
'email.bounced': 'bounced',
4564
'email.failed': 'failed',
65+
'email.complained': 'complained',
66+
'email.suppressed': 'suppressed',
67+
'email.scheduled': 'scheduled',
4668
}
4769

4870
interface ResendEvent {
@@ -190,8 +212,14 @@ function redactOtpFromSubject(
190212
)
191213
}
192214

193-
function logResendEvent(
215+
function isLoggedResendEvent(
194216
event: ResendEvent,
217+
): event is ResendEvent & { type: LoggedResendEventType } {
218+
return (LOGGED_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)
219+
}
220+
221+
function logResendEvent(
222+
event: ResendEvent & { type: LoggedResendEventType },
195223
svixId: string,
196224
otpLength: number,
197225
otpCharset: OtpCharset,
@@ -206,7 +234,7 @@ function logResendEvent(
206234
subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset),
207235
}
208236
const message = 'Received email event'
209-
if (event.type === 'email.delivery_delayed') {
237+
if ((WARNING_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)) {
210238
logger.warn(fields, message)
211239
} else {
212240
logger.info(fields, message)
@@ -237,6 +265,19 @@ export function createResendWebhookRouter(
237265
}
238266

239267
const eventId = result.headers['svix-id']
268+
if (!isLoggedResendEvent(result.event)) {
269+
logger.debug(
270+
{
271+
provider: 'resend',
272+
eventId,
273+
eventType: result.event.type.slice('email.'.length),
274+
},
275+
'Ignored unsupported email webhook event',
276+
)
277+
res.status(200).json({ received: true, ignored: true })
278+
return
279+
}
280+
240281
const eventFromAddress = parseSingleEmailAddress(result.event.data.from)
241282
if (eventFromAddress !== expectedFromAddress) {
242283
logger.debug(

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default defineConfig({
2626
statements: 58,
2727
branches: 57,
2828
functions: 71,
29-
lines: 56,
29+
lines: 57,
3030
},
3131
},
3232
},

0 commit comments

Comments
 (0)