Trackily Docs

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.

Email — Suppression

Concept

Les ESP sérieux distinguent trois mécanismes :

  1. 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.
  2. Suppression globale (email_suppression row) — la personne dit "stop pour TOUT chez ce sender". Aucune liste n'a le droit de la contacter. C'est immuable côté worker.
  3. 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) et email_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_suppression en 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. Check list_email_suppression avec l'email en question.
  • Webhook Mailgun bouncé qui ne peuple pas la suppression — vérifie que le webhook secret est correct dans Settings → Integrations et que l'URL /webhook/mailgun est 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