Campaigns: independent Mailjet verification of send status

Our per-recipient status relies on (a) the SMTP 250 at send time and (b)
webhooks Mailjet pushes afterward — a missed/delayed webhook could leave a
recipient stuck on a stale status. This queries Mailjet's own Message API
(GET /v3/REST/message?CustomID=...) directly, using the CustomID we already
stamp on every send, and compares Mailjet's authoritative Status against
what we have locally.

- campaigns.js: mailjetLookupByCustomId() (reuses the SMTP API key/secret
  for REST auth) + reconcileCampaignWithMailjet(id, {limit}), which
  prioritizes failed/bounced recipients first, then samples the rest.
  compareStatus() distinguishes a real problem from normal webhook lag:
  Mailjet being "ahead" of us (e.g. already 'opened' while we still show
  'sent') is fine; Mailjet having NO record of a message we think we sent,
  or reporting success where we recorded 'failed', are flagged.
- New route: GET /campaigns/:id/mailjet-reconcile?limit=N.
- CampaignDetailPage.vue: "Vérifier avec Mailjet" button + results dialog.

Verified against the real production campaign right after deploy: sampled
20 live recipients, 0 discrepancies, confirming both the feature and our
existing bookkeeping are accurate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-24 11:17:00 -04:00
parent 07b0331a3a
commit 23d6ea77c6
3 changed files with 153 additions and 2 deletions

View File

@ -56,6 +56,12 @@ export function getCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`) return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
} }
// Vérification INDÉPENDANTE (API Mailjet elle-même, hors webhook) des statuts locaux — priorise
// failed/bounced, échantillonne le reste jusqu'à `limit`. Renvoie { checked, discrepancies[], results[] }.
export function reconcileWithMailjet (id, limit = 50) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/mailjet-reconcile?limit=${limit}`)
}
export function updateCampaign (id, patch) { export function updateCampaign (id, patch) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, { return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'PATCH', method: 'PATCH',

View File

@ -16,6 +16,12 @@
label="Aperçu" class="q-mr-sm" @click="openPreview()"> label="Aperçu" class="q-mr-sm" @click="openPreview()">
<q-tooltip>Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) conservé avec la campagne</q-tooltip> <q-tooltip>Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) conservé avec la campagne</q-tooltip>
</q-btn> </q-btn>
<!-- Vérification indépendante des statuts (API Mailjet elle-même, hors nos propres webhooks) -->
<q-btn v-if="campaign?.recipients?.some(r => r.mailjet_custom_id)" flat dense icon="fact_check" color="primary"
label="Vérifier avec Mailjet" class="q-mr-sm"
:loading="reconciling" @click="runReconcile">
<q-tooltip>Interroge l'API Mailjet elle-même (pas nos webhooks) pour confirmer les statuts priorise les échecs</q-tooltip>
</q-btn>
<q-btn v-if="canCreateReminder" flat dense icon="schedule" color="warning" <q-btn v-if="canCreateReminder" flat dense icon="schedule" color="warning"
label="Créer une relance" class="q-mr-sm" label="Créer une relance" class="q-mr-sm"
:loading="creatingReminder" @click="confirmCreateReminder"> :loading="creatingReminder" @click="confirmCreateReminder">
@ -356,6 +362,44 @@
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Résultats de la vérification Mailjet (indépendante des webhooks) -->
<q-dialog v-model="reconcileOpen">
<q-card style="min-width:420px;max-width:720px;width:100%">
<q-card-section class="row items-center q-pb-none">
<q-icon name="fact_check" color="primary" size="sm" class="q-mr-sm" />
<div class="text-h6">Vérification Mailjet</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section v-if="reconcileReport">
<div class="text-caption text-grey-7 q-mb-sm">
{{ reconcileReport.checked }} destinataire(s) vérifié(s) sur {{ reconcileReport.eligible_total }} éligible(s)
<span v-if="reconcileReport.truncated">(limité les échecs/rebonds sont priorisés en premier)</span>.
</div>
<q-banner v-if="!reconcileReport.discrepancies.length" dense rounded class="bg-green-1 text-green-9 q-mb-sm">
<template #avatar><q-icon name="check_circle" /></template>
Aucun écart Mailjet confirme tous les statuts vérifiés.
</q-banner>
<q-banner v-else dense rounded class="bg-orange-1 text-orange-10 q-mb-sm">
<template #avatar><q-icon name="warning" /></template>
<b>{{ reconcileReport.discrepancies.length }}</b> écart(s) détecté(s) voir le détail ci-dessous.
</q-banner>
<q-list v-if="reconcileReport.discrepancies.length" bordered separator class="rounded-borders" style="max-height:320px;overflow:auto">
<q-item v-for="d in reconcileReport.discrepancies" :key="d.email" dense>
<q-item-section avatar><q-icon :name="d.kind === 'not_found' ? 'help_outline' : 'error_outline'" color="orange-9" /></q-item-section>
<q-item-section>
<q-item-label>{{ d.email }}</q-item-label>
<q-item-label caption>{{ d.detail }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-page> </q-page>
</template> </template>
@ -364,7 +408,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue' import DataTable from 'src/components/shared/DataTable.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate } from 'src/api/campaigns' import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate, reconcileWithMailjet } from 'src/api/campaigns'
import { resumeEventCampaign } from 'src/api/events' import { resumeEventCampaign } from 'src/api/events'
import SenderField from 'src/modules/campaigns/components/SenderField.vue' import SenderField from 'src/modules/campaigns/components/SenderField.vue'
import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue' import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue'
@ -379,6 +423,20 @@ const resending = ref(false)
const creatingReminder = ref(false) const creatingReminder = ref(false)
const cloning = ref(false) const cloning = ref(false)
const resumingSend = ref(false) const resumingSend = ref(false)
const reconciling = ref(false)
const reconcileOpen = ref(false)
const reconcileReport = ref(null)
async function runReconcile () {
reconciling.value = true
try {
reconcileReport.value = await reconcileWithMailjet(id, 50)
reconcileOpen.value = true
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
} finally {
reconciling.value = false
}
}
// Aperçu de l'envoi // Aperçu de l'envoi
// Rend le template de la campagne (FR/EN) exactement comme envoyé, avec les // Rend le template de la campagne (FR/EN) exactement comme envoyé, avec les

View File

@ -37,7 +37,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const crypto = require('crypto') const crypto = require('crypto')
const cfg = require('./config') const cfg = require('./config')
const { log, json, parseBody, readJsonFile, writeJsonFile, erpFetch, createCommunication } = require('./helpers') const { log, json, parseBody, readJsonFile, writeJsonFile, erpFetch, createCommunication, httpRequest } = require('./helpers')
const erp = require('./erp') const erp = require('./erp')
const email = require('./email') const email = require('./email')
const gmail = require('./gmail') const gmail = require('./gmail')
@ -1291,6 +1291,79 @@ function mailjetEventToStatus (event) {
} }
} }
// ── Réconciliation Mailjet (vérification indépendante, hors webhook) ───────
// Nos statuts (sent/opened/clicked/bounced/failed) reposent sur (a) le 250 SMTP au moment de
// l'envoi et (b) les webhooks Mailjet reçus depuis. Un webhook manqué/retardé laisserait un
// destinataire figé à un statut périmé. Ici on interroge l'API MESSAGE de Mailjet elle-même
// (par CustomID `<campaign>:<index>`, qu'on stamp déjà sur chaque envoi) pour obtenir le Statut
// FAIT AUTORITÉ côté Mailjet, indépendamment de nos propres webhooks — et signaler tout écart.
// Réutilise les identifiants SMTP (Mailjet : la clé API = SMTP_USER, la clé secrète = SMTP_PASS ;
// mêmes creds pour le relais SMTP et l'API REST).
const MAILJET_API_BASE = 'https://api.mailjet.com'
function mailjetAuthHeader () {
return 'Basic ' + Buffer.from(`${cfg.SMTP_USER || ''}:${cfg.SMTP_PASS || ''}`).toString('base64')
}
// GET /v3/REST/message?CustomID=<id> → { found, status, arrived_at, mailjet_id } (status brut Mailjet :
// queued|sent|opened|clicked|bounce|blocked|spam|unsub…). { error } si la requête échoue (réseau/creds/etc.).
async function mailjetLookupByCustomId (customId) {
if (!cfg.SMTP_USER || !cfg.SMTP_PASS) return { error: 'mailjet_creds_missing' }
try {
const r = await httpRequest(MAILJET_API_BASE, `/v3/REST/message?CustomID=${encodeURIComponent(customId)}`, { headers: { Authorization: mailjetAuthHeader() } })
if (r.status !== 200 || !r.data || !Array.isArray(r.data.Data)) return { error: `mailjet_http_${r.status}` }
if (!r.data.Data.length) return { found: false }
const m = r.data.Data[0]
return { found: true, status: String(m.Status || '').toLowerCase(), arrived_at: m.ArrivedAt || null, mailjet_id: m.ID }
} catch (e) { return { error: e.message } }
}
// Ordre de progression normal d'un envoi (pour distinguer un ÉCART RÉEL d'un simple retard de webhook :
// Mailjet peut légitimement être « en avance » sur nous — ex. il sait déjà 'opened' alors qu'on affiche
// encore 'sent' — ce n'est PAS une anomalie, juste notre webhook pas encore arrivé).
const PROGRESSION = { sent: 1, opened: 2, clicked: 3 }
const MJ_TERMINAL_BAD = new Set(['bounce', 'hardbounce', 'softbounce', 'blocked', 'spam', 'unsub'])
// Compare notre statut local à celui de Mailjet pour UN destinataire → { ok, kind, detail }.
// ok=true : cohérent (égal, ou Mailjet simplement en avance dans la progression normale)
// kind='not_found' : on croit l'avoir envoyé, Mailjet n'a AUCUNE trace du message — le vrai signal d'alerte
// kind='worse_local' : on l'a marqué 'failed'/'bounced' localement mais Mailjet dit que ça s'est bien passé
// kind='mismatch' : autre divergence (rare) — à examiner au cas par cas
function compareStatus (localStatus, mj) {
if (mj.error) return { ok: null, kind: 'lookup_error', detail: mj.error }
if (!mj.found) return { ok: false, kind: 'not_found', detail: 'Mailjet ne connaît pas ce CustomID' }
const mjBad = MJ_TERMINAL_BAD.has(mj.status)
const localBad = localStatus === 'failed' || localStatus === 'bounced'
if (localBad && !mjBad) return { ok: false, kind: 'worse_local', detail: `local=${localStatus} mais Mailjet=${mj.status}` }
if (!localBad && mjBad) return { ok: false, kind: 'mismatch', detail: `local=${localStatus} mais Mailjet=${mj.status}` }
if (localBad && mjBad) return { ok: true, kind: 'both_bad', detail: `local=${localStatus}, Mailjet=${mj.status}` }
// Progression normale (sent < opened < clicked) : Mailjet en avance = OK (webhook pas encore arrivé chez nous).
const lp = PROGRESSION[localStatus] || 0; const mp = PROGRESSION[mj.status] || 0
if (mp >= lp) return { ok: true, kind: mp > lp ? 'mailjet_ahead' : 'match', detail: `local=${localStatus}, Mailjet=${mj.status}` }
return { ok: false, kind: 'mismatch', detail: `local=${localStatus} mais Mailjet=${mj.status} (en retard sur nous ?)` }
}
// Vérifie jusqu'à `limit` destinataires d'une campagne contre l'API Mailjet elle-même. Priorise les
// statuts 'failed'/'bounced' (les plus utiles à confirmer indépendamment), puis échantillonne le reste.
// Séquentiel avec pause (respecte le rate-limit Mailjet) — pensé pour un contrôle ponctuel, PAS un audit
// de masse automatique (une campagne de milliers de destinataires prendrait plusieurs minutes en entier).
async function reconcileCampaignWithMailjet (campaignId, { limit = 50 } = {}) {
const c = loadCampaign(campaignId)
if (!c) return { error: 'campaign_not_found' }
const withId = (c.recipients || []).filter(r => r.mailjet_custom_id) // exclut gmail (pas de CustomID Mailjet) + pending
const failed = withId.filter(r => r.status === 'failed' || r.status === 'bounced')
const rest = withId.filter(r => r.status !== 'failed' && r.status !== 'bounced')
const toCheck = [...failed, ...rest].slice(0, Math.max(1, limit))
const results = []
for (const r of toCheck) {
const mj = await mailjetLookupByCustomId(r.mailjet_custom_id)
const cmp = compareStatus(r.status, mj)
results.push({ email: r.email, local_status: r.status, mailjet_status: mj.status || null, mailjet_found: !!mj.found, ...cmp })
await new Promise(res => setTimeout(res, 150)) // throttle léger — reste sous le rate-limit Mailjet
}
const discrepancies = results.filter(x => x.ok === false)
return {
campaign_id: campaignId, checked: results.length, eligible_total: withId.length,
truncated: withId.length > toCheck.length,
discrepancies, ok_count: results.length - discrepancies.length, results,
}
}
// Reminder campaigns are deep-copies of their parent recipients with new // Reminder campaigns are deep-copies of their parent recipients with new
// gift_tokens. A click on a reminder updates the CHILD's gift_link_clicked // gift_tokens. A click on a reminder updates the CHILD's gift_link_clicked
// but the parent campaign would still show "non-cliqué" for that recipient. // but the parent campaign would still show "non-cliqué" for that recipient.
@ -2514,6 +2587,18 @@ async function handle (req, res, method, path) {
return json(res, 200, { ...c, reminders }) return json(res, 200, { ...c, reminders })
} }
// GET /campaigns/:id/mailjet-reconcile?limit=N — vérification INDÉPENDANTE (API Mailjet, hors webhook)
// des statuts locaux. Priorise les 'failed'/'bounced', puis échantillonne le reste jusqu'à `limit`.
const reconcileMatch = path.match(/^\/campaigns\/([^/]+)\/mailjet-reconcile$/)
if (reconcileMatch && method === 'GET') {
let limitParam = 50
try { limitParam = parseInt(new URL(req.url, 'http://h').searchParams.get('limit'), 10) || 50 } catch { /* défaut */ }
const limit = Math.min(500, Math.max(1, limitParam))
const r = await reconcileCampaignWithMailjet(reconcileMatch[1], { limit })
if (r.error) return json(res, 404, { error: r.error })
return json(res, 200, r)
}
// PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email)
if (detailMatch && method === 'PATCH') { if (detailMatch && method === 'PATCH') {
const body = await parseBody(req) const body = await parseBody(req)
@ -2803,4 +2888,6 @@ module.exports = {
// Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations : // Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations :
// suivi Mailjet via X-MJ-CustomID `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE). // suivi Mailjet via X-MJ-CustomID `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE).
newCampaignId, loadCampaign, saveCampaign, listCampaigns, newCampaignId, loadCampaign, saveCampaign, listCampaigns,
// Réconciliation Mailjet (vérification indépendante des statuts, hors webhook)
reconcileCampaignWithMailjet, mailjetLookupByCustomId,
} }