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:
parent
07b0331a3a
commit
23d6ea77c6
|
|
@ -56,6 +56,12 @@ export function getCampaign (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) {
|
||||
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@
|
|||
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-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"
|
||||
label="Créer une relance" class="q-mr-sm"
|
||||
:loading="creatingReminder" @click="confirmCreateReminder">
|
||||
|
|
@ -356,6 +362,44 @@
|
|||
</div>
|
||||
</q-card>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -364,7 +408,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|||
import DataTable from 'src/components/shared/DataTable.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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 SenderField from 'src/modules/campaigns/components/SenderField.vue'
|
||||
import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue'
|
||||
|
|
@ -379,6 +423,20 @@ const resending = ref(false)
|
|||
const creatingReminder = ref(false)
|
||||
const cloning = 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 ───────────────────────────────────────────────────────
|
||||
// Rend le template de la campagne (FR/EN) exactement comme envoyé, avec les
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const fs = require('fs')
|
|||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
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 email = require('./email')
|
||||
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
|
||||
// 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.
|
||||
|
|
@ -2514,6 +2587,18 @@ async function handle (req, res, method, path) {
|
|||
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)
|
||||
if (detailMatch && method === 'PATCH') {
|
||||
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 :
|
||||
// suivi Mailjet via X-MJ-CustomID `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE).
|
||||
newCampaignId, loadCampaign, saveCampaign, listCampaigns,
|
||||
// Réconciliation Mailjet (vérification indépendante des statuts, hors webhook)
|
||||
reconcileCampaignWithMailjet, mailjetLookupByCustomId,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user