Email Suppression
La suppression list, c'est la blocklist GLOBALE de Trackily. Un email dedans est interdit d'enrollment ET d'envoi, sur toutes les listes simultanément. C'est la pierre angulaire de ta réputation de sender — la traiter à la légère, c'est se faire bannir par Gmail dans la semaine.

Concept
Les ESP sérieux distinguent trois mécanismes :
- Unsubscribe par liste (
email_subscribers.status='unsubscribed') — la personne dit "stop pour cette newsletter spécifique, mais OK pour les autres". L'opérateur peut la réenroller plus tard sur une liste différente, légitimement. - Suppression globale (
email_suppressionrow) — la personne dit "stop pour TOUT chez ce sender". Aucune liste n'a le droit de la contacter. C'est immuable côté worker. - Bounce/complaint feedback — le MTA (Mailgun, SES…) te dit "cet email rebondit hard, ou la personne a cliqué Mark As Spam". Trackily répercute automatiquement vers
email_suppression.
La suppression globale est cross-list par design : si quelqu'un te marque comme spam depuis la liste A, le sentiment se transfère à toutes tes autres listes (même domaine d'envoi, même IP). Mieux vaut ne plus jamais le contacter, point.
Pourquoi c'est critique
Trois raisons techniques :
1. Sender reputation
Les providers (Gmail, Outlook) calculent un score de réputation pour ton IP + ton domaine. Trois inputs principaux :
- Complaint rate :
% (Mark As Spam) sur total envois. Au-dessus de 0.3%, tu commences à passer en spam. À 1%, tu es blacklisté. - Bounce rate :
% hard bounces sur total envois. Au-dessus de 5%, idem. - Engagement : opens, clicks, replies. Le 0 absolu (envoi à des adresses mortes) tue la réputation.
Si tu n'as pas de suppression list, tu ré-envoies à des adresses qui ont déjà bounce / complained → ton score explose → tous tes mails partent en spam, même vers des destinataires en or.
2. Coût
Mailgun et compagnie te facturent par tentative d'envoi, pas par succès. Renvoyer 50k mails à des adresses suppressed, c'est 50k$/mois en pure perte (au pricing typique).
3. Compliance
GDPR + CAN-SPAM + CASL : un opt-out doit être honoré universellement et immédiatement. Une personne qui se désinscrit d'une liste et te recontacte deux mois plus tard parce qu'elle a reçu un autre mail de toi via une liste différente → plainte CNIL légitime.
Le modèle de données
Schema email_suppression (volontairement minimaliste) :
| Colonne | Type | Note |
|---|---|---|
id |
SERIAL PK | |
email |
TEXT NOT NULL | normalisé en lowercase (cf. index unique) |
reason |
TEXT NOT NULL | bounce_hard, bounce_soft_persistent, complaint, unsubscribe, manual, purge_request |
added_at |
TIMESTAMPTZ DEFAULT NOW() | |
notes |
TEXT | libre, pour traçabilité |
Index unique : UNIQUE (lower(email)) — un email = une ligne max.
Qu'est-ce qui peuple cette table ?
Automatique
| Événement | Source | Reason stocké |
|---|---|---|
| Hard bounce reçu via webhook MTA | Mailgun/SES/Postmark webhook | bounce_hard |
| Soft bounce répété (~5 fois) sur même adresse | Worker, batch nightly | bounce_soft_persistent |
| Complaint / FBL (feedback loop) | Webhook MTA | complaint |
Click sur lien {{unsubscribe_url}} |
/email/unsub/<token> endpoint |
unsubscribe (selon politique opérateur) |
List-Unsubscribe one-click (Gmail/Outlook) |
POST sur l'endpoint List-Unsubscribe | unsubscribe |
Manuel
- Tool
add_to_email_suppression - Bouton "Add to suppression" dans l'UI (page Suppression)
- Demande GDPR de suppression → INSERT direct côté ops
Comment Trackily applique la suppression
À trois moments distincts :
1. Au moment de l'enrollment
// enrollSubscriber() short-circuit
const isSuppressed = await pool.query(
`SELECT 1 FROM email_suppression WHERE lower(email) = lower($1)`,
[email]
);
if (isSuppressed.rowCount) {
return { ok: true, skipped: true, reason: 'suppressed' };
}
C'est silencieux : enroll_email_subscriber retourne status='ok' mais avec un flag skipped: true. Le subscriber n'est pas créé, aucun email ne sera envoyé. Comportement intentionnel pour éviter que l'opérateur (ou un script qui spam-enroll) puisse réveiller un email mort.
2. Au moment du scheduling
Quand le worker compute "quels sends queued pour quels subscribers", il join email_suppression et exclut les hits. Cela couvre le cas où un subscriber a été enrôlé AVANT d'être suppressed (la suppression a été ajoutée entre l'enrollment et le scheduling).
3. Au moment de l'envoi
Dernière vérification avant l'appel transport.sendMail(). Belt-and-suspenders : si la table grandit entre le scheduling et le tick d'envoi, on rattrape ici.
Ajouter à la suppression
Via MCP
// Request
{
"name": "add_to_email_suppression",
"arguments": {
"email": "marie@example.com",
"reason": "manual",
"notes": "GDPR removal request 2026-05-18 ticket #142"
}
}
// Response
{
"status": "ok",
"tool": "add_to_email_suppression",
"message": "Email added to global suppression list",
"row": {
"id": 1024,
"email": "marie@example.com",
"reason": "manual",
"notes": "GDPR removal request 2026-05-18 ticket #142",
"added_at": "2026-05-18T18:22:11.421Z"
}
}
Idempotent : ré-appeler avec le même email retourne la row existante (ON CONFLICT DO NOTHING + RETURNING).
Via l'UI
Sidebar → Email Marketing → Suppression → Add. Coller l'email, choisir une reason dans le dropdown, optionnellement saisir une note (pour le ticket de support, la date de demande GDPR, etc.).
Lister la suppression
// Request
{ "name": "list_email_suppression", "arguments": { "limit": 50 } }
// Response (excerpt)
{
"status": "ok",
"total": 1024,
"suppression": [
{
"id": 1024,
"email": "marie@example.com",
"reason": "manual",
"notes": "GDPR removal request 2026-05-18 ticket #142",
"added_at": "2026-05-18T18:22:11.421Z"
},
{
"id": 1023,
"email": "bounced@dead.example",
"reason": "bounce_hard",
"notes": "Mailgun event delivery-failure (550 5.1.1)",
"added_at": "2026-05-18T17:11:02.812Z"
}
]
}
Retirer de la suppression (uniquement à la main)
Volontairement non exposé en MCP. Un LLM qui retire des emails de la suppression à grande échelle = catastrophe. Si tu dois vraiment retirer un email (faux positif documenté, demande explicite de la personne) :
DELETE FROM email_suppression WHERE lower(email) = lower('marie@example.com');
À faire en console PSQL avec une connexion authentifiée. Documente la raison dans un commentaire ou un ticket interne.
Seuils de surveillance
À monitorer mensuellement, avec ces query rapides :
-- Croissance de la suppression sur 30 jours
SELECT reason, COUNT(*) AS n
FROM email_suppression
WHERE added_at >= NOW() - INTERVAL '30 days'
GROUP BY reason
ORDER BY n DESC;
-- Taux de bounce sur les 30 derniers jours
SELECT
COUNT(*) FILTER (WHERE status = 'bounced')::float / NULLIF(COUNT(*), 0) AS bounce_rate
FROM email_sends
WHERE created_at >= NOW() - INTERVAL '30 days';
Si bounce_rate > 3%, tu envoies à des listes périmées ou achetées — arrête tout et nettoie. Si complaint/jour augmente sensiblement, ton contenu pose problème (subject misleading, fréquence trop élevée, ciblage à côté).
Anti-patterns
- Vider la table pour "tester" — tu détruis l'historique de protection. Une fois supprimés, les hard-bounces vont re-bounce à la prochaine campagne et tuer ta réputation à la racine.
- Désactiver le check au scheduling — déjà arrivé "pour livrer une dernière fois" → 800 plaintes en une matinée → Mailgun ferme le compte.
- Confondre
unsubscribed(par liste) etemail_suppression(global) — un unsub depuis la liste A ne supprime PAS de la liste B par défaut. Si la politique opérateur est "un unsub = global", ajoute manuellement àemail_suppressionen plus de l'unsub par liste. - Ré-importer un CSV ancien sans dédup — ça enroll en masse des adresses suppressed (qui sont silencieusement skipped) mais aussi des adresses jamais consenties. Toujours filtrer le CSV contre la suppression list AVANT import.
Erreurs courantes
- "Subscribers s'enrollent mais ne reçoivent rien" — souvent un hit silencieux sur
email_suppression. Checklist_email_suppressionavec l'email en question. - Webhook Mailgun bouncé qui ne peuple pas la suppression — vérifie que le webhook secret est correct dans
Settings → Integrationset que l'URL/webhook/mailgunest bien atteignable depuis l'extérieur. - Doublons en suppression — impossible côté DB (UNIQUE index lowercase). Si tu en vois deux pour la "même" adresse, c'est qu'une casse différente est passée en lowercase à un moment et plus à l'autre — bug à signaler.
Voir aussi
- Lists — où vivent les
email_subscribers - Sequences — quand on planifie un send, on check la suppression
- SMTP — d'où viennent les webhooks bounce/complaint
- API — Webhooks — comment Mailgun/SES nous remontent les bounces