Skip to content

Commit d275b33

Browse files
committed
feat(auth): log Resend email open events
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>
1 parent fa9e740 commit d275b33

6 files changed

Lines changed: 62 additions & 37 deletions

File tree

.changeset/resend-delivery-webhooks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
'ePDS': minor
33
---
44

5-
Optional delivery-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.
5+
Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.
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`; 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`; 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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,9 @@ SMTP_PASS=
214214
SMTP_FROM=noreply@pds.example
215215
SMTP_FROM_NAME=ePDS
216216

217-
# Resend users only (optional): delivery webhook signing secret. ePDS supports
218-
# any SMTP provider and does not require Resend. To opt into Resend delivery
219-
# event logs, configure https://auth.pds.example/webhooks/resend and set:
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:
220220
# RESEND_WEBHOOK_SECRET=whsec_...
221221
# Account-wide Resend events are filtered to the exact SMTP_FROM address.
222222

docs/configuration.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -198,24 +198,30 @@ 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 delivery event logs
201+
#### Optional Resend email event logs
202202

203203
ePDS does **not** require Resend: operators can use any SMTP provider. This
204204
provider-specific integration is only for operators who already send through
205-
Resend and choose to log its delivery webhooks. Without
205+
Resend and choose to log its email webhooks. Without
206206
`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted.
207207

208208
| Variable | Description |
209209
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
210210
| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. |
211211

212-
To opt into delivery-event logging for email sent through Resend:
212+
To opt into email-event logging for email sent through Resend:
213213

214214
1. In the Resend dashboard, register `https://<AUTH_HOSTNAME>/webhooks/resend`.
215-
2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`.
216-
3. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy the auth service.
217-
4. Send a test sign-in code through the production SMTP configuration and
218-
confirm that both `email.sent` and `email.delivered` reach the receiver.
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.
219225
Resend's documentation does not explicitly guarantee that SMTP-submitted
220226
email emits webhooks, so verify this before relying on the logs.
221227

@@ -231,8 +237,10 @@ a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`,
231237
`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced
232238
with `[REDACTED]` to prevent it from reaching logs while preserving the rest of
233239
the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`,
234-
`bounced`, or `failed`. Normal events use `info`;
235-
`delayed` uses `warn`. No webhook data is persisted by ePDS. The
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
236244
provider-specific rate limit permits 300 webhook requests per minute per source
237245
IP.
238246

packages/auth-service/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ SMTP_FROM=noreply@pds.example
139139
SMTP_FROM_NAME=ePDS
140140

141141
# Resend users only (optional). ePDS supports any SMTP provider and does not
142-
# require Resend. To opt into Resend delivery event logs, register
142+
# 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.bounced, and email.failed, then set:
144+
# email.delivery_delayed, email.opened, email.bounced, and email.failed, then set:
145145
# RESEND_WEBHOOK_SECRET=whsec_...
146146
# Account-wide Resend events are filtered to the exact SMTP_FROM address.
147147

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,22 @@ describe('Resend webhook receiver', () => {
119119
email: 'person@example.com',
120120
subject: '[REDACTED] — Your sign-in code',
121121
},
122-
'Received email delivery event',
122+
'Received email event',
123+
)
124+
})
125+
126+
it('normalizes open-tracking events', async () => {
127+
const result = await postWebhook(makeEvent('email.opened'))
128+
129+
expect(result).toEqual({ status: 200, json: { received: true } })
130+
expect(logInfo).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
provider: 'resend',
133+
eventType: 'opened',
134+
messageId: 'resend-email-123',
135+
email: 'person@example.com',
136+
}),
137+
'Received email event',
123138
)
124139
})
125140

@@ -200,7 +215,7 @@ describe('Resend webhook receiver', () => {
200215
eventType: 'delayed',
201216
messageId: 'resend-email-123',
202217
}),
203-
'Received email delivery event',
218+
'Received email event',
204219
)
205220
})
206221

@@ -222,15 +237,15 @@ describe('Resend webhook receiver', () => {
222237
expect(logInfo).not.toHaveBeenCalled()
223238
})
224239

225-
it('rejects signed event types that are not used for delivery logs', async () => {
226-
const result = await postWebhook(makeEvent('email.opened'))
240+
it('rejects signed event types that are not used for email logs', async () => {
241+
const result = await postWebhook(makeEvent('email.received'))
227242

228243
expect(result.status).toBe(400)
229244
expect(result.json).toEqual({ error: 'Invalid webhook payload' })
230245
expect(logInfo).not.toHaveBeenCalled()
231246
})
232247

233-
it('rejects a delivery event without a recipient email', async () => {
248+
it('rejects an email event without a recipient email', async () => {
234249
const event = makeEvent()
235250
const data = event.data as Record<string, unknown>
236251
data.to = []

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

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
2-
* Resend delivery webhook receiver.
2+
* Resend email webhook receiver.
33
*
44
* The route consumes the untouched request body, verifies Resend's Svix
5-
* signature before inspecting the payload, accepts only delivery events used
6-
* by ePDS, and emits one structured log entry per delivery attempt. Resend may
7-
* retry the same source ID (logged as `eventId`) and may deliver an email's
8-
* events out of order, so log consumers must deduplicate and order events when
9-
* calculating latency.
5+
* signature before inspecting the payload, accepts only email events used by
6+
* ePDS, and emits one structured log entry per event. Resend may retry the same
7+
* source ID (logged as `eventId`) and may deliver an email's events out of
8+
* order, so log consumers must deduplicate and order events when calculating
9+
* latency.
1010
*/
1111
import express, { Router } from 'express'
1212
import addressparser from 'nodemailer/lib/addressparser/index.js'
@@ -21,27 +21,29 @@ const RESEND_EVENT_TYPES = [
2121
'email.sent',
2222
'email.delivered',
2323
'email.delivery_delayed',
24+
'email.opened',
2425
'email.bounced',
2526
'email.failed',
2627
] as const
2728

2829
type ResendEventType = (typeof RESEND_EVENT_TYPES)[number]
29-
type EmailDeliveryEventType =
30+
type EmailEventType =
3031
| 'sent'
3132
| 'delivered'
3233
| 'delayed'
34+
| 'opened'
3335
| 'bounced'
3436
| 'failed'
3537
type OtpCharset = 'numeric' | 'alphanumeric'
3638

37-
const NORMALIZED_EVENT_TYPES: Record<ResendEventType, EmailDeliveryEventType> =
38-
{
39-
'email.sent': 'sent',
40-
'email.delivered': 'delivered',
41-
'email.delivery_delayed': 'delayed',
42-
'email.bounced': 'bounced',
43-
'email.failed': 'failed',
44-
}
39+
const NORMALIZED_EVENT_TYPES: Record<ResendEventType, EmailEventType> = {
40+
'email.sent': 'sent',
41+
'email.delivered': 'delivered',
42+
'email.delivery_delayed': 'delayed',
43+
'email.opened': 'opened',
44+
'email.bounced': 'bounced',
45+
'email.failed': 'failed',
46+
}
4547

4648
interface ResendEvent {
4749
type: ResendEventType
@@ -203,7 +205,7 @@ function logResendEvent(
203205
email: event.data.to[0],
204206
subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset),
205207
}
206-
const message = 'Received email delivery event'
208+
const message = 'Received email event'
207209
if (event.type === 'email.delivery_delayed') {
208210
logger.warn(fields, message)
209211
} else {

0 commit comments

Comments
 (0)