From 23d6ea77c66efb9e4e012b5025c9c3e2e8a3168e Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 24 Jul 2026 11:17:00 -0400 Subject: [PATCH] Campaigns: independent Mailjet verification of send status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ops/src/api/campaigns.js | 6 ++ .../campaigns/pages/CampaignDetailPage.vue | 60 ++++++++++++- services/targo-hub/lib/campaigns.js | 89 ++++++++++++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index d054663..97bc5eb 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -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', diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index 11dea58..ad20705 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -16,6 +16,12 @@ label="Aperçu" class="q-mr-sm" @click="openPreview()"> Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) — conservé avec la campagne + + + Interroge l'API Mailjet elle-même (pas nos webhooks) pour confirmer les statuts — priorise les échecs + @@ -356,6 +362,44 @@ + + + + + + +
Vérification Mailjet
+ + +
+ +
+ {{ reconcileReport.checked }} destinataire(s) vérifié(s) sur {{ reconcileReport.eligible_total }} éligible(s) + (limité — les échecs/rebonds sont priorisés en premier). +
+ + + Aucun écart — Mailjet confirme tous les statuts vérifiés. + + + + {{ reconcileReport.discrepancies.length }} écart(s) détecté(s) — voir le détail ci-dessous. + + + + + + {{ d.email }} + {{ d.detail }} + + + +
+ + + +
+
@@ -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 diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 5d0926b..f734ef0 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -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 `:`, 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= → { 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 `:` → 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, }