Email logs
Tracking des emails envoyés via Resend — statuts livré/bounce/complained/opened, page admin, webhook.
Tous les emails envoyés via lib/email.ts ou les broadcasts sont loggés en
DB. Les statuts (delivered, bounced, complained, opened) sont mis à jour par
le webhook Resend.
Modèle de données
model EmailLog {
id String @id @default(cuid())
userId String?
email String
subject String
templateKey String? // welcome / magic-link / broadcast / null = ad-hoc
providerId String? @unique // Resend message id (clé du webhook)
status String @default("queued") // queued | sent | delivered | bounced | complained | failed
deliveredAt DateTime?
bouncedAt DateTime?
complainedAt DateTime?
openedAt DateTime?
errorMsg String?
createdAt DateTime @default(now())
}Hook automatique
lib/email.ts → sendTemplated() capture systématiquement le résultat
Resend et appelle logEmail :
const result = await resend.emails.send({ from, to, subject, html });
await logEmail({
to: opts.to,
subject,
templateKey: opts.templateKey,
providerId: result.data?.id ?? null,
errorMsg: result.error?.message ?? null,
});Idem côté lib/broadcasts.ts (templateKey = "broadcast"). Si l'envoi
throw (réseau), logEmail est aussi appelé avec errorMsg pour ne
pas perdre la trace.
Redaction auto des codes sensibles
logEmail masque les chiffres de 4+ dans le subject pour les templates
email-verification, two-factor-code, magic-link, password-reset :
"Ton code : 123456" → "Ton code : ******"Pour qu'un admin lisant les logs ne puisse pas reusiner un code OTP.
Webhook Resend
Endpoint : POST /api/webhooks/resend. Configure dans Resend dashboard
→ Webhooks :
- URL :
${SITE_URL}/api/webhooks/resend - Events :
email.delivered,email.bounced,email.complained,email.opened - Secret : copie dans
.env→RESEND_WEBHOOK_SECRET=whsec_...
Resend signe via Svix (headers svix-id, svix-timestamp,
svix-signature). Le code vérifie HMAC-SHA256 sur
${svix-id}.${svix-timestamp}.${rawBody}, refuse les replays > 5 min,
puis met à jour la row matching providerId.
Page admin
/admin/emails/logs — liste paginée avec :
- Recherche email / sujet
- Filtres status (all / delivered / sent / bounced / complained / failed)
- Vue table (desktop) + cards (mobile) via
<DataList> - Détail par row : statut, template, erreur si échec
Onglet Emails sur la fiche user (/admin/users → click) → 50 derniers
envois pour cet user (utile pour le support : "pourquoi je ne reçois pas mes
mails ?").
Allez plus loin
- Webhooks Stripe — pattern d'idempotence comparable
- Emails templates — édition des templates côté admin