Sequences
Une sequence, c'est l'autoresponder : une suite ordonnée de steps qui partent au bout de N minutes/heures/jours après l'enrollment d'un subscriber dans la liste parente. La première séquence créée sur une liste (cible « commande active ») devient automatiquement sa
default_sequence— celle que tout nouvel abonné déclenche. Une séquence peut aussi viser les commandes annulées (relance, code promo) : section « Cible ».

Concept
Une sequence vit toujours dans le contexte d'une liste (email_sequences.list_id NOT NULL). Tu peux avoir N sequences sur une liste, mais une seule est marquée comme default_sequence côté liste : c'est celle qui se déclenche automatiquement à chaque enroll_email_subscriber. Les autres sequences existent pour des envois manuels, des A/B tests, ou des flows déclenchés par script.
Chaque sequence est composée de steps ordonnés par position. Pour chaque step, tu définis :
- Un délai depuis l'enrollment (
delay_days+delay_hours+delay_minutes) — note : c'est un delta depuis le moment où le subscriber a été enrôlé, pas depuis le step précédent. Ça rend la lecture de la sequence triviale ("Day 0, Day 1, Day 3, Day 7…") et évite les drifts cumulés. - Un subject + preheader
- Un body_html + body_text (le HTML est dominant ; le text est généré automatiquement si tu laisses vide, mais le fournir explicitement améliore la délivrabilité)
- Un internal name facultatif (label dans le builder, séparé du subject)
- Un flag
is_activepour désactiver un step sans le supprimer
Le modèle de données
email_sequences :
| Colonne | Type | Note |
|---|---|---|
id |
SERIAL PK | |
list_id |
INTEGER FK NOT NULL | la liste parente |
name |
TEXT | label humain |
description |
TEXT | optionnel |
is_active |
BOOLEAN | si false, les enrollments ne déclenchent rien |
total_steps |
INTEGER | dénormalisé pour l'UI |
cible_commande |
TEXT NOT NULL DEFAULT 'active' |
v137 — active (parcours normal) ou annulee (relance après annulation). Choisie à la création, ne change plus. Voir « Cible » plus bas |
deleted_at |
TIMESTAMPTZ | soft-delete |
objectifs / objectifs_match |
JSONB NOT NULL DEFAULT '[]' / TEXT NOT NULL DEFAULT 'une' |
v143 — objectifs de sortie (« s'arrête dès qu'il achète »). Voir « Parcours : conditions et objectifs » |
email_sends.order_id (INTEGER, v137, sans défaut) : la commande qui a inscrit l'envoi, figée à sa programmation. NULL = envoi sans commande (prospect d'une landing, panier abandonné, séquence de funnel déclenchée sans commande : on_submitted, on_abandon) : jamais relu. Une séquence de funnel qui suit une commande (on_order, on_approved, on_declined) la porte — voir « Limites connues ».
email_sequence_steps :
| Colonne | Type | Note |
|---|---|---|
id |
SERIAL PK | |
sequence_id |
INTEGER FK NOT NULL | |
position |
INTEGER | ordre d'affichage et de tri (0, 1, 2…) |
delay_days / delay_hours / delay_minutes |
INTEGER | depuis l'enrollment, pas depuis le step précédent |
subject |
TEXT NOT NULL | |
preheader |
TEXT | inbox preview, ~110 chars |
body_html |
TEXT | obligatoire (peut être minimal) |
body_text |
TEXT | fallback plain-text |
name |
TEXT | internal label (ex: "Day 0 — Welcome") |
from_name_override |
TEXT | écrase la liste/SMTP juste pour ce step |
reply_to_override |
TEXT | idem |
is_active |
BOOLEAN | step désactivable sans suppression |
conditions / conditions_match |
JSONB NOT NULL DEFAULT '[]' / TEXT NOT NULL DEFAULT 'toutes' |
v143 — condition d'envoi, jugée au départ (« seulement si l'étape 1 n'a pas été ouverte »). Voir « Parcours : conditions et objectifs » |
Créer une sequence
Via MCP
// Request
{
"name": "create_email_sequence",
"arguments": {
"list_id": 7,
"name": "Welcome series",
"description": "3-step onboarding for sweepstakes buyers"
}
}
// Response
{
"status": "ok",
"tool": "create_email_sequence",
"message": "Email sequence created. It is the default sequence of list 7.",
"sequence": {
"id": 12,
"list_id": 7,
"name": "Welcome series",
"is_active": true,
"total_steps": 0
}
}
Note la phrase de retour : si c'est la première sequence créée sur la liste, le tool met aussi à jour email_lists.default_sequence_id = 12. C'est intentionnel : 99% du temps, c'est ce que l'opérateur veut. Si tu veux changer la default plus tard, fais un update_email_list({ id: 7, default_sequence_id: <other> }).
Via l'UI
Sidebar → Email Marketing → Sequences → New sequence. Tu sélectionnes la liste parente, donnes un nom, choisis la Cible (plus bas), et le builder vide s'ouvre. Tu cliques "+ Add step" pour ajouter le premier step.
Cible : commande active ou commande annulée (v137)
Décision n° 5 de l'opérateur (« un statut commande annulée, et choisir ce statut à la création d'une séquence, pour relancer autrement, avec des codes promo par exemple »), réalisée pour WhatsApp au §30.5 de docs/whatsapp/CONCEPTION.md, et pour l'e-mail depuis la v137. AVANT, email-engine.js ne lisait jamais cancelled_at : le « votre colis arrive » d'une séquence post-achat partait après une annulation, et aucune séquence ne pouvait parler au client après elle.
Code : lib/email-cible-commande.js (la garde, la commande d'entrée), lib/email-annulations.js (le balayage), lib/email-sequences-admin.js (création, modification, séquence par défaut — partagé par l'écran et l'outil MCP).
Le lien abonné → commande
L'inscription post-achat (triggerCommerceEmailEnrollment) pose custom_fields.order_id et custom_fields.commerce_event = 'post_purchase' ; la dernière inscription gagne. Seule une inscription post-achat est « par une commande » : le panier abandonné pose aussi order_id (commerce_event = 'abandoned_cart'), mais son sort normal est d'être annulé d'office au bout d'une heure — le relire couperait la relance de panier.
La commande d'un envoi est figée à sa programmation (email_sends.order_id) : celle des envois déjà écrits pour le couple (abonné, séquence) — la commande d'entrée —, sinon celle que l'appelant donne (le balayage des annulations ; une issue de funnel qui suit une commande : on_order la commande du funnel, on_approved l'upsell accepté, on_declined la commande principale du parcours), sinon, pour la séquence par défaut de la liste, la commande post-achat que l'abonné porte. Une étape ajoutée plus tard hérite de la commande d'entrée.
Une commande se juge avec sa famille. Un funnel à upsell écrit la principale puis son upsell (parent_order_id), et les deux inscrivent post-achat : l'abonné porte l'upsell. Annuler la principale (la liste des commandes cache les upsells, et l'annulation ne cascade pas) annule la famille : une commande est annulée si elle l'est ou si sa principale l'est, et un upsell de la même famille ne « tient » jamais à la place de sa principale.
active — le parcours normal (défaut)
Toute séquence d'avant la v137 est active, et garde son comportement. Une seule différence, la garde d'annulation : au départ, une étape inscrite par une commande désormais annulée est sautée (skipped, last_error = commande_annulee : …). Sauf si l'abonné porte depuis une commande post-achat d'une autre famille qui tient (il a racheté et s'est réinscrit) : l'étape part. Une commande disparue : commande_introuvable — sauf, là aussi, si la commande portée tient (le doublon supprimé par l'opérateur pendant que l'abonné porte la vraie commande : la séquence du vrai client continue). Un envoi sans commande n'est jamais relu. Le remboursement seul n'arrête rien : « annulée » = orders.cancelled_at, rien d'autre.
Le corps dit la commande que la garde a validée ({{order_number}}, {{order_table}}…) : la commande portée si elle tient, sinon la commande figée si elle tient — jamais une commande annulée (les variables se rendent vides). Une relance dit la commande annulée qu'elle relance. Un envoi sans commande figée ni commande post-achat portée (panier abandonné, prospect) lit custom_fields.order_id, comme avant.
annulee — la relance après annulation
- L'entrée : à chaque tick (60 s), avant la sélection des envois, le balayage lit les commandes principales annulées récentes (un upsell refusé n'est pas une commande annulée) et fait entrer l'abonné que chacune a inscrit — par l'inscription post-achat (il porte la principale ou l'un de ses upsells), ou par une étape de funnel qui a figé cette commande sur ses envois (
on_order,on_approved,on_declined: l'acheteur d'un funnel est relancé même sans liste post-achat configurée) — dans chaque séquenceannuleeactive de sa liste. L'étape J+0 part dans le même passage. - Les bornes (celles de WhatsApp) : seulement les annulations postérieures à la création de la séquence ; de moins de 7 jours ; une fois par client et par séquence (pas de boucle « j'annule pour avoir un code ») ; jamais quand une autre commande du même client tient — même adresse (casse ignorée), principale, non annulée, payée ou COD, passée dans les 30 jours autour de l'annulation (le doublon annulé pendant que la vraie commande part). Désinscrit, liste ou séquence en pause : pas d'entrée.
- L'ancre des délais : J+0 = le moment de l'annulation. Une étape déjà échue à l'entrée est écrite
skipped(etape_echue) : jamais de relance en retard. Sauf l'arriéré du balayage : un passage prend au plus 200 annulations en 2 s, et une annulation en masse en laisse derrière lui. Un client encore à l'heure quand le balayage a commencé à en laisser derrière lui (annulé au plus 5 minutes avant) entre avec l'ancre décalée à son entrée : J+0 part tout de suite, les étapes suivantes gardent leur écart — jamais perdu, jamais en paquet. AVANT, au-delà de 5 minutes, il perdait son J+0. - Au départ, la garde est inversée : la relance ne part que si l'annulation tient (
commande_activesinon) et qu'aucune autre commande du client ne tient (commande_remplacee: il vient de racheter). - Jamais séquence par défaut : ni automatiquement (la première séquence
actived'une liste le devient, comme avant ; uneannuleejamais), ni parupdate_email_list/PUT /admin/api/email/lists/:id(refus en clair). Elle n'entre que par le balayage : programmée depuis l'inscription, elle n'écrirait rien. Le sélecteur de séquences d'un funnel ne la propose donc pas (ni une séquence supprimée), et le passage live d'un funnel qui la porte est refusé (live_sequence_relance,lib/funnel-compose.js) — AVANT, elle était proposée, acceptée, et le funnel ne l'envoyait jamais. - La cible ne change plus :
PUT /admin/api/email/sequences/:idrefuse un changement (reposer la même valeur passe). Basculer une vieille séquence ferait entrer des annulations qu'elle n'attendait pas. - Le code promo : un code fixe (un code de réduction natif créé à l'avance) se met dans le corps de l'étape. Des codes uniques par client ne sont pas couverts.
Écran et MCP
Écran : dans la boîte d'une séquence, un choix Cible (« Commande active — parcours normal » / « Commande annulée — relance après annulation »), grisé en modification, avec l'aide qui dit la conséquence avant d'enregistrer ; une colonne Cible dans la table (pastille « Commandes annulées ») et la pastille en tête du builder. L'export CSV de la table emporte la cible (active | annulee) et l'import la relit par la même règle que l'écran (creerSequence) : une relance réimportée reste une relance, jamais la séquence par défaut — AVANT, elle revenait active et par défaut. MCP : create_email_sequence reçoit cible_commande (active | annulee) et sa réponse (next) dit la suite ; une cible inconnue est un refus lisible (isError) ; add_email_sequence_step dit l'ancre ; update_email_list refuse une relance par défaut.
Limites connues
- Une séquence de funnel déclenchée sans commande (
on_submitted,on_abandon) n'est pas relue. Celles qui suivent une commande (on_order,on_approved,on_declined) la figent depuis le 2026-09-30 et sont gardées comme les autres ; celles programmées avant ne portent pas de commande et partent comme avant. - La relance ne voit une autre commande que sous la même adresse (casse ignorée) : un rachat sous une autre adresse n'est pas vu.
- L'arriéré du balayage est retenu en mémoire, par processus : un redémarrage pendant une annulation en masse juge les clients encore en attente comme après une panne (J+0 sauté s'il a plus de 5 minutes).
- Les envois programmés avant la v137 ne portent pas de commande : ils partent comme avant.
- La passe de rafraîchissement ne concerne que la séquence par défaut : une étape ajoutée à une relance après l'entrée d'un client ne lui est pas programmée.
Ajouter des steps
Step Day 0 (envoi immédiat)
{
"name": "add_email_sequence_step",
"arguments": {
"sequence_id": 12,
"name": "Day 0 — Welcome",
"position": 0,
"delay_days": 0,
"delay_hours": 0,
"delay_minutes": 5,
"subject": "Bienvenue {{first_name}} ! Voici ce qui t'attend",
"preheader": "Un cadeau de bienvenue + 3 trucs à savoir avant de continuer",
"body_html": "<p>Salut {{first_name}},</p><p>Merci d'avoir rejoint <strong>{{list_name}}</strong>. Voici ton code de bienvenue : <code>WELCOME15</code>.</p><p><a href=\"https://example.com/checkout?code=WELCOME15\">Utiliser mon code</a></p>",
"body_text": "Salut {{first_name}},\n\nMerci d'avoir rejoint {{list_name}}. Voici ton code de bienvenue : WELCOME15.\n\nLien : https://example.com/checkout?code=WELCOME15"
}
}
Le delay_minutes: 5 évite que le mail parte EXACTEMENT en même temps que le double opt-in (s'il y en a un) ou le reçu de commande — ce qui ferait deux mails simultanés et abîmerait l'expérience.
Step Day 1 (reminder)
{
"name": "add_email_sequence_step",
"arguments": {
"sequence_id": 12,
"name": "Day 1 — Reminder",
"position": 1,
"delay_days": 1,
"subject": "Tu n'as pas encore utilisé ton code, {{first_name}} ?",
"preheader": "Petit rappel — WELCOME15 expire dans 48 h",
"body_html": "<p>Salut {{first_name}},</p><p>Le code <strong>WELCOME15</strong> qu'on t'a envoyé hier est toujours actif, mais plus pour longtemps : <strong>expire dans 48 h</strong>.</p><p><a href=\"https://example.com/checkout?code=WELCOME15\">Récupérer mon offre</a></p>"
}
}
Step Day 3 (last call)
{
"name": "add_email_sequence_step",
"arguments": {
"sequence_id": 12,
"name": "Day 3 — Last call",
"position": 2,
"delay_days": 3,
"subject": "Dernier rappel : ton WELCOME15 expire ce soir",
"preheader": "C'est maintenant ou jamais",
"body_html": "<p>{{first_name}},</p><p>C'est la dernière fois qu'on t'en parle. Le code <strong>WELCOME15</strong> expire <strong>ce soir à minuit</strong>.</p><p>Si tu veux le tester avant qu'il disparaisse : <a href=\"https://example.com/checkout?code=WELCOME15\">click ici</a>.</p><p>Sinon, no worries — on a plein d'autres trucs cool dans la suite de la newsletter.</p>"
}
}
Éditer un step
{
"name": "update_email_sequence_step",
"arguments": {
"id": 47,
"subject": "Tu n'as pas encore utilisé WELCOME15, {{first_name}} ?",
"delay_days": 2
}
}
Tous les champs sont optionnels — tu envoies seulement ce que tu modifies. Pour désactiver un step sans le supprimer : { id, is_active: false }. Pour le réactiver : { id, is_active: true }.
Dans l'admin, le corps d'un step se compose aussi sans écrire de HTML : bouton « Éditeur visuel » à côté de la zone HTML, voir Éditeur visuel. Le projet de l'éditeur est gardé sur le step (body_design) ; un body_html modifié à la main — ou écrit par un agent sans body_design — le détache.
Parcours : conditions et objectifs
Depuis la v143 (contrat : docs/email/CONCEPTION-PARCOURS.md), une séquence n'est plus forcément une suite fixe. Deux réglages, jugés au moment d'envoyer — le calendrier ne change pas, chaque étape reste programmée à l'inscription comme avant :
- la condition d'envoi d'une étape (
conditions,conditions_match) : fausse au départ → cet envoi passeskippedaveclast_error = 'condition_non_remplie'; - les objectifs de sortie de la séquence (
objectifs,objectifs_match) : atteint au départ d'un envoi → cet envoi et toutes les étapes encore en file de la séquence pour cet abonné passentskippedaveclast_error = 'objectif_atteint'.
Rien n'est envoyé, aucune tentative n'est consommée, et une étape sautée reste sautée (le rattrapage ne la reprogramme jamais). Une étape sans condition et une séquence sans objectif se comportent exactement comme avant.
Une sortie est définitive. Dès qu'un envoi de la séquence porte objectif_atteint pour un abonné, il ne reçoit plus aucune étape de cette séquence — même une étape ajoutée après, même si l'objectif redevient faux (achat annulé, fenêtre de jours dépassée). Seule limite : si tu retires tous les objectifs de la séquence, une étape sans condition n'est plus relue (aucune requête sans règle) et partirait.
Attendre plutôt que trancher trop tôt. Quand la condition vise une étape qui n'est pas encore partie (queued/sending : arriéré, plafond SMTP, autre conteneur), l'envoi est reporté de 30 min ; quand elle vise une étape reçue depuis moins de 12 h avec un n_a_pas_* (« n'a pas ouvert »), il est reporté à réception + 12 h — le temps de laisser ouvrir. Un report garde le statut queued, ne consomme aucune tentative et réécrit seulement scheduled_at. Au-delà de 7 jours après l'échéance d'origine (mise en file + délai de l'étape), l'envoi est jugé tel quel.
La langue des conditions
C'est celle des segments (get_email_segment_fields) : inscrit, ouverture, clic, envoi_ponctuel, pays, source, champ_perso, achat, conversion, activite. Elle juge l'adresse de l'abonné. Deux ajouts propres aux parcours :
| Ajout | Forme | Sens |
|---|---|---|
champ etape |
{ champ: "etape", op, valeur: <id d'étape> }, op ∈ a_ouvert, n_a_pas_ouvert, a_clique, n_a_pas_clique |
le dernier envoi (id le plus grand) de cette étape à cet abonné — des doublons anciens ne rendent jamais vraies deux branches opposées. valeur = une étape de la même séquence programmée avant : délai total (jours, heures, minutes) strictement plus court ; la position dans la liste ne compte pas. n_a_pas_* exige que l'étape ait été reçue (sent) : une étape sautée rend n_a_pas_* faux (pas encore partie : report, ci-dessus). Un clic prouve l'ouverture (pixel bloqué) : qui a cliqué « a ouvert ». Refusé dans un objectif. |
depuis_entree: true |
sur achat / a_achete et conversion / a_converti |
seulement un achat (une conversion) postérieur à l'entrée de l'abonné dans la séquence (la première mise en file de ses étapes, sinon son inscription). Un achat annulé ne compte jamais. |
Bornes : 10 conditions par étape, 5 objectifs par séquence ; un identifiant (étape, boutique, offre, envoi) au plus 2 147 483 647. conditions_match vaut toutes (ET) par défaut ; objectifs_match vaut une par défaut (la séquence s'arrête dès que l'un des objectifs est atteint). Un champ ou un opérateur inconnu → 400 condition_invalide, qui nomme la condition (« Condition n° 2 : … »).
Les refus à l'écriture
- une condition
etapequi vise une étape d'une autre séquence, elle-même, ou une étape au délai égal ou plus long →400 condition_invalide; raccourcir le délai d'une étape au niveau (ou sous) celui de l'étape que sa condition vise, aussi ; - supprimer une étape visée par la condition d'une autre, ou porter son délai au niveau (ou au-delà) de celui de l'étape qui la vise →
409 etape_visee, qui nomme l'étape qui la vise (etape_visee_par: { id, name, rang }). Retire d'abord cette condition. Déplacer une étape dans la liste (sa position) ne casse rien.
Les numéros d'étape (« l'étape 1 », rang, choix du catalogue) suivent le calendrier : délai, puis position.
Exemple 1 — relancer ceux qui n'ont pas ouvert
{ "name": "add_email_sequence_step", "arguments": {
"sequence_id": 12, "delay_days": 2,
"subject": "Tu as raté ça, {{first_name}} ?", "body_html": "<p>…</p>",
"conditions": [{ "champ": "etape", "op": "n_a_pas_ouvert", "valeur": 47 }]
} }
Le serveur rend conditions_libelle: "seulement si l’étape 1 n’a pas été ouverte". Celui qui a ouvert (ou cliqué) l'étape 1 ne reçoit pas la relance ; celui dont l'étape 1 a été sautée non plus — il n'a rien reçu à ne pas ouvrir. Celui dont l'étape 1 est partie il y a moins de 12 h attend : la relance est reportée à réception + 12 h.
Exemple 2 — l'embranchement cliqué / pas cliqué
Deux étapes au même délai, conditions opposées sur la même étape antérieure :
{ "name": "add_email_sequence_step", "arguments": { "sequence_id": 12, "delay_days": 3, "name": "Offre",
"subject": "Ton offre", "body_html": "<p>…</p>",
"conditions": [{ "champ": "etape", "op": "a_clique", "valeur": 48 }] } }
{ "name": "add_email_sequence_step", "arguments": { "sequence_id": 12, "delay_days": 3, "name": "Rappel",
"subject": "Un dernier mot", "body_html": "<p>…</p>",
"conditions": [{ "champ": "etape", "op": "n_a_pas_clique", "valeur": 48 }] } }
Pour un abonné qui a reçu l'étape 2, exactement une des deux part (celle « pas cliqué » attend 12 h après la réception si besoin). Si l'étape 2 est sautée, aucune des deux ne part ; si elle est encore en file, les deux attendent (report). Le parcours les marque embranchement: true.
Exemple 3 — sortir de la séquence à l'achat
{ "name": "update_email_sequence", "arguments": { "id": 12,
"objectifs": [{ "champ": "achat", "op": "a_achete", "depuis_entree": true }] } }
objectifs_libelle: "s’arrête dès qu’il a acheté depuis son entrée dans la séquence". Sans depuis_entree, un client qui avait déjà acheté avant d'entrer sortirait au premier envoi : c'est presque toujours depuis_entree: true qu'on veut pour une séquence de vente. objectifs: [] retire l'objectif.
Routes et outils
| Besoin | Route | Outil MCP |
|---|---|---|
| condition d'une étape | POST /admin/api/email/sequences/:id/steps, PUT /admin/api/email/steps/:id (conditions, conditions_match ; réponse avec conditions_libelle) |
add_email_sequence_step, update_email_sequence_step |
| objectifs d'une séquence | POST /admin/api/email/lists/:id/sequences, PUT /admin/api/email/sequences/:id (objectifs, objectifs_match ; réponse avec objectifs_libelle) |
create_email_sequence, update_email_sequence |
| les phrases | GET /admin/api/email/sequences/:id, …/:id/steps, /admin/api/email/steps/:id |
get_email_sequence_detail |
| le parcours sur 30 jours | GET /admin/api/email/sequences/:id/parcours |
get_email_sequence_journey |
| le catalogue d'une étape | GET /admin/api/email/sequences/:id/conditions/champs?avant_step=<id> |
get_email_sequence_condition_fields |
Le parcours rend, pour chaque étape, step_id, position, rang, name, les délais, conditions_libelle, embranchement (au moins deux étapes conditionnelles au même délai) et ses chiffres 30 jours : envoyes (datés par l'envoi), sautees_condition et sorties_objectif (datés par l'échéance de l'envoi, la table ne garde pas l'instant du saut) ; pour la séquence, objectifs_libelle et sorties_objectif_total (les abonnés sortis : une sortie saute plusieurs étapes, elle compte une fois). Le catalogue reprend celui des segments (chaque champ dit objectif: true s'il vaut aussi pour un objectif, et depuis_entree liste les opérateurs qui l'acceptent) et ajoute en tête le champ etape, dont les choix sont les étapes antérieures à avant_step (sans avant_step : toutes, pour une étape ajoutée en fin).
Coût et ordre au départ
Au départ d'un envoi de séquence (jamais une confirmation, jamais un envoi ponctuel), sendOne lit les règles dans sa requête habituelle : aucune requête de plus sans condition ni objectif, une seule sinon (objectif et condition, pour un seul abonné). Le jugement vient après la garde d'annulation de commande (son verdict ne change pas) et avant le choix du serveur SMTP (un envoi sauté n'est pas retenu). Ordre : sortie déjà prise ou objectif atteint → objectif_atteint ; trop tôt pour trancher → report ; condition fausse → condition_non_remplie. Une règle illisible ne fait jamais échouer un envoi : JSON retouché à la main, ou requête du parcours qui lève → l'envoi est sauté avec last_error = 'condition_illisible', journalisé sans adresse, jamais marqué failed.
Constructeur visuel (diagramme)
Contrat : docs/email/CONCEPTION-PARCOURS-VISUEL.md. Aucune migration : le diagramme est une VUE des étapes, de leurs délais et de leurs conditions ; chaque geste passe par les routes et la validation existantes (condition_invalide, etape_visee… inchangés). Une séquence faite à la main ou par MCP s'affiche telle quelle.
| Besoin | Route | Outil MCP |
|---|---|---|
| le diagramme | GET /admin/api/email/sequences/:id/graphe |
get_email_sequence_graph |
| la table des contraires | GET /admin/api/email/sequences/graphe/contraires |
get_email_branch_opposites |
| créer une branche « si / sinon » | POST /admin/api/email/sequences/:id/branche |
create_email_sequence_branch |
Le graphe (lib/email-parcours-graphe*.js) : sequence (entree_libelle : « Entrée : inscription à la liste « … » », ou l'annulation d'une commande pour une relance), rangs triés par délai total (jours, heures, minutes — la règle d'« antérieure » des conditions) avec delai_libelle (« Jour 0 », « Jour 3 · 12 h ») et delai_suivant_minutes (+ 1 jour : le délai proposé par « + Étape après »), et dans chaque rang les nœuds dans l'ordre des positions : etape_id, numero (au calendrier), nom, sujet, conditions_libelle, branche, stats_30j (envoyes, ouverts — un clic prouve l'ouverture —, cliques, sautees_condition, sorties_objectif, taux_ouverture et taux_clic en texte, « — » sans envoi), a_un_design, nb_blocs, actif, brouillon (corps vide) ; puis objectifs ({ libelle, sorties_30j } ou null) et alertes (phrases). L'écran ne recalcule ni les rangs, ni les branches, ni les taux. Trois requêtes quel que soit le nombre d'étapes (la séquence, les étapes en colonnes nommées — ni projet d'éditeur, ni blocs, ni corps —, une agrégation des envois sur 30 jours) ; 100 étapes se lisent en quelques millisecondes sur la base locale.
Une branche = deux étapes au même délai, une condition chacune, l'une la contraire exacte de l'autre (même champ, même valeur, mêmes jours ; opérateurs contraires) : branche: { groupe, role: 'si' | 'sinon', libelle } (« Si l'étape 1 a été ouverte » / « Sinon » ; « si » = la plus petite position). Deux étapes au même délai qui ne sont pas opposées → role: 'autre' et une alerte (« deux étapes partent au même moment pour certains contacts »). Supprimer une étape d'une branche ne supprime pas l'autre : elle redevient seule (branche: null) ou autre.
La table des contraires est dérivée des champs connus (ceux des segments + le champ etape) : a_ouvert↔n_a_pas_ouvert, a_clique↔n_a_pas_clique, est↔n_est_pas, a↔n_a_pas, a_visite↔n_a_pas_visite, a_achete↔n_a_jamais_achete, a_converti↔n_a_pas_converti, egal↔different, existe↔n_existe_pas, depuis_moins_de↔depuis_plus_de, superieur_ou_egal↔inferieur_a — un couple n'y entre que si ses deux opérateurs existent pour le champ. Deux conditions contraires ne sont jamais vraies ensemble pour un contact ; elles ne couvrent pas toujours tout le monde (remarque) : un contact qui n'a pas reçu l'étape ou l'envoi ponctuel visé ne prend aucune des deux branches. Sans contraire : chaleur / est, champ_perso / contient, envoi_ponctuel / a_recu, et toute condition depuis_entree: true.
Créer une branche : { delai_minutes, condition: { champ, op, … }, si: { nom?, sujet? }, sinon: { nom?, sujet? } } → { si, sinon, graphe }. Les deux étapes sont créées en une transaction (si la seconde échoue, la première n'existe pas), en fin de liste, la seconde avec la condition contraire calculée par le serveur ; chacune passe par la validation existante (étape visée programmée avant, étiquettes qui existent). Refus : 400 branche_impossible (opérateur sans contraire, condition à plusieurs lignes, condition absente), 400 condition_invalide, 400 requete_invalide (délai de 0 à 10 ans en minutes, nom ≤ 200, sujet ≤ 500), 404 introuvable.
Une étape sans contenu ne part jamais. Avant ce lot, sendOne envoyait le corps d'une étape tel quel — une étape active sans corps partait vide (le seul pied de page), et une étape désactivée après la mise en file partait quand même. Quatre règles le ferment (contrat §6 règles 11-12, §7 règles 18-23) :
- « Vide » = sans texte visible (
lib/email-texte-visible.js, UNE fonction pour la garde, le départ et le graphe) : le corps compte s'il y a du texte visible dansbody_html— commentaires,<head>,<style>,<script>, balises retirés, entités décodées ( est un blanc), espaces Unicode et caractères de largeur nulle retirés ; une image avec une source compte aussi.<p></p>,<p> </p>,<!-- -->, U+200B sont vides ;body_textseul ne suffit pas (l'e-mail HTML s'afficherait vide). Le sujet suit la même règle. Limite connue : un texte caché par le CSS (display:none) compte comme visible. - Au départ,
sendOnerelit l'étape (règle 18) : inactive → envoiskipped(last_error = 'etape_inactive') ; sujet ou corps sans texte visible →skipped('etape_vide'). Aucune tentative consommée, rien n'est envoyé — même pour un envoi mis en file avant la désactivation. Un envoi ponctuel et une confirmation ne sont pas concernés. is_activeest un booléen (règle 19) :trueoufalse(absent = la valeur par défaut : active à la création, inchangée en modification) ; toute autre valeur ('false',0,null,'off','n'…) → 400requete_invalide. La garde rend la valeur, la route écrit celle-là.- Une étape vide ne s'active pas (
lib/email-etape-garde.js) : créer une étape active (POST /admin/api/email/sequences/:id/steps,add_email_sequence_step) ou modifier une étape pour qu'elle soit active (PUT /admin/api/email/steps/:id,update_email_sequence_step) exige un sujet et un corps visibles → sinon 400etape_vide, dont le message nomme ce qui manque (« le sujet », « le corps » ; aussi enmanque: [...]). Activer un brouillon vide, ou vider le sujet ou le corps d'une étape qui reste active, est refusé ; désactiver passe toujours. Un brouillon se crée vide :is_active: falsesans sujet ni corps est permis (route et MCP). La garde juge l'état final sous verrou : dès qu'une modification touche l'activité ou le contenu, une transaction s'ouvre, la ligne est relueFOR UPDATEet l'UPDATE suit — deux modifications simultanées (l'une active, l'autre vide) ne passent plus toutes les deux. Les refus MCP sont desisError(aussirequete_invalidesanssequence_id/id, rien à modifier, etintrouvable). Les étapes déjà actives et vides en base ne sont pas modifiées : les renommer, les déplacer, changer leur délai passe ; leurs envois sont sautés au départ.
Les deux étapes d'une branche naissent inactives ; on les écrit, puis on les active (is_active: true). Activer une étape écrite se comporte alors comme en ajouter une (le rattrapage du moteur ne repasse que la séquence par défaut d'une liste).
Ce que le graphe dit en plus (règles 20, 21, 24) : chaque nœud porte actif (vrai seulement si is_active est vrai — null n'est pas programmé), manque (["le sujet", "le corps"], vide si rien ne manque) et brouillon (il manque quelque chose) ; chaque branche si / sinon porte remarque (la phrase de la table des contraires quand un contact peut ne prendre aucune voie, sinon null). Alertes : des étapes non appariées au même délai (« deux étapes partent au même moment pour certains contacts ») ; plusieurs branches au même délai, ou une branche et une étape seule (« plusieurs e-mails partent au même moment pour certains contacts ») ; une condition qui vise une étape inactive ou sans contenu (jamais reçue : aucune voie n'est prise) ; une étiquette visée qui n'existe plus ; un brouillon inactif, une étape inactive, une étape active à qui il manque le sujet ou le corps (« ses envois sont sautés au départ »). Les listes d'étapes d'une alerte sont bornées (5 nommées, puis « et N autres »). Les branches se reconnaissent en temps linéaire (une clé canonique par étape) : 3 000 étapes au même délai se rangent en quelques millisecondes. delai_minutes d'une branche est un entier décimal (nombre, ou chiffres seuls : "0x5A0", "1e3", 1.5 sont refusés).
Variables disponibles dans subject + preheader + body_html
Rendues au moment de l'envoi, en faisant un lookup sur le subscriber + sa liste :
| Variable | Source |
|---|---|
{{first_name}} |
premier mot de email_subscribers.name, fallback partie locale de l'email |
{{name}} |
email_subscribers.name |
{{email}} |
email_subscribers.email |
{{country}} |
email_subscribers.country_code |
{{custom.X}} |
email_subscribers.custom_fields->>'X' |
{{unsubscribe_url}} |
lien one-click signé, valide à vie |
{{list_name}} |
email_lists.name |
Le moteur n'échappe pas les variables — si tu interpoles {{name}} dans du HTML, assure-toi que name est safe (la validation côté enroll_email_subscriber n'autorise pas les < ni >, mais ne fais pas confiance aveuglément).
Send Time Optimization (STO)
Si tu actives sto_enabled: true sur la liste, chaque step planifié n'est pas envoyé à l'heure "naturelle" (= enrolled_at + delay), mais snappé à l'heure préférée du subscriber.
L'heure préférée est calculée à partir de ses email_events de type open : on regarde l'histogramme des heures d'ouverture sur les 90 derniers jours, et on stocke le pic dans email_subscribers.preferred_send_hour (SMALLINT, 0-23 UTC).
Tant qu'on n'a pas appris la préférence (nouveau subscriber, ou peu d'opens), on retombe sur email_lists.sto_default_hour (par défaut 10h UTC).
Le gain documenté dans l'industrie est de +15 à +25% sur l'open rate. À activer une fois que tu as 2-3 sequences en flight et que tu peux faire un avant/après crédible.
Test d'envoi
Avant de sauvegarder un step, envoie-toi un test :
{
"name": "send_test_email",
"arguments": {
"to": "moi@example.com",
"subject": "Bienvenue Marie ! Voici ce qui t'attend",
"body_html": "<p>Salut Marie,</p>...",
"list_id": 7,
"custom_fields": { "first_name": "Marie" }
}
}
Le tool envoie synchrone, bypass la queue, et ne logge PAS dans email_sends ni email_events (pour ne pas polluer les stats). Il est en revanche noté sur le serveur SMTP (succès compté, erreur de serveur notée : voir SMTP). Aucun tracking pixel ni footer auto-injecté — tu vois exactement ce que ta sequence enverra (moins le rendu final du tracker/footer).
Lister + inspecter
// list_email_sequences (filtre optionnel par list_id)
{ "name": "list_email_sequences", "arguments": { "list_id": 7 } }
// get_email_sequence_detail (settings + steps + preview body 200 chars)
{ "name": "get_email_sequence_detail", "arguments": { "id": 12 } }
Santé des séquences
« Va envoyer ? », calculé par le serveur pour chaque séquence (2026-10-05),
sur le modèle de la page SMTP et du §35 WhatsApp (docs/whatsapp/CONCEPTION.md).
AVANT, GET /admin/api/email/sequences ne rendait que les lignes brutes : une
séquence dont la liste n'avait pas de serveur SMTP, dont le serveur était
désactivé ou dont une étape était vide se lisait « active ».
La route. GET /admin/api/email/sequences?detail=1 rend
{ sequences, sante, alerte }. Sans l'option, la réponse d'avant (le tableau
s.* + list_name), à l'identique. Chaque séquence garde ses champs et gagne :
verdict—{ code, st, libelle, motif, a_faire }:stvautok,warn,bad,idle,info, ounullquand le serveur ne sait pas (l'écran affiche « — ») ;motifdit pourquoi,a_fairele geste (ounull) ;activite—abonnes: { actifs },abonnes_en_cours(abonnés distincts qui ont un envoi en file),en_file,dues(en file et échus),prochaine_echeance,envoyes_7j,echecs_7j,rebonds_7j(la date d'un événement est celle de la page SMTP :sent_atpour un envoi parti,scheduled_atpour un échec ou un rebond) ;serveur— le verdict du serveur SMTP de sa liste,{ id, peut_envoyer, etat, motif, libelle }, celui deverdictSmtplu danslisterDetail(la page SMTP, l'outillist_email_smtp_serverset le tick) — sans nom ni hôte, qui restent derrière le droit des réglages ;nullsans serveur.
sante : total, la partition par st (ok, warn, bad, idle, info,
non_juges — la somme vaut total), par_code, et les sommes de l'activité.
alerte : la plus grave (bad avant warn, puis le rang dans la table),
{ code, st, libelle, nombre, sequences }, ou null.
Les verdicts — L’ORDRE EST LA RÈGLE (la première ligne qui s'applique l'emporte) :
| code | st | quand |
|---|---|---|
inactive |
idle | en pause : ni la liste ni un funnel n'y inscrivent ; la file déjà écrite part. Si c'est la séquence d'accueil (par défaut) de sa liste, le motif le dit — pendant la pause, les nouveaux abonnés entrent dans la liste sans séquence — et le geste est « la réactiver ou choisir une autre séquence d'accueil » |
liste_inactive |
bad | liste désactivée ou supprimée |
aucune_etape |
bad | aucune étape active |
etapes_sans_contenu |
bad | une étape active sans objet, ou sans HTML ni texte |
sans_serveur |
bad | la liste n'a pas de serveur SMTP : envois retenus, puis échec 24 h après l'échéance |
non_juge |
— | l'état des serveurs SMTP n'a pas pu être lu |
serveur_bloque |
bad | le serveur est désactivé ou bloqué (verdictSmtp : inactif, bloque) |
serveur_en_attente |
warn | plafond atteint (attente) : les envois attendent, sans perdre de tentative |
serveur_a_verifier |
warn | serveur jamais vérifié (a_verifier) |
liste_vide |
idle | aucun abonné actif |
cible_annulee |
info | relance après annulation (v137) : n'accueille qu'à l'annulation |
hors_defaut |
info | pas la séquence par défaut : seul un funnel l'alimente |
prete |
ok | tout le reste |
La pause est respectée par le moteur (2026-10-05). queueSequence
(email-engine.js) — par où passent l'inscription, la confirmation d'un double
opt-in, le passage du moteur, les funnels et le balayage des annulations —
n'inscrit plus personne dans une séquence en pause ou supprimée, même restée
séquence d'accueil de sa liste : l'abonné entre dans la liste sans
séquence, rien n'est mis en file. AVANT, la requête ne lisait que
is_active de l'étape : une séquence d'accueil mise en pause accueillait
encore chaque nouvel inscrit, et le verdict devait le dire en warn. Les
envois déjà en file partent quand même (annule-les dans le journal des
envois). À la réactivation, le passage du moteur (passerRattrapage, chaque
minute) fait entrer les abonnés de la liste qui n'y sont pas encore. Leurs
délais se comptent depuis ce rattrapage (2026-10-06) : l'inscrit de la pause
reçoit J0 au passage, J+2 deux jours plus tard. AVANT, ils se comptaient depuis
son inscription : J0, J+2, J+5… déjà échus partaient tous au même passage. Un
abonné qui a déjà au moins un envoi dans la séquence (même annulé ou
supprimé) garde son calendrier : une étape ajoutée se date toujours depuis son
inscription. Le passage avance par pages de 500 abonnés actifs (un curseur sur
l'identifiant, toutes listes confondues, qui repart du début une fois la fin
atteinte) : chaque abonné est atteint, une grosse instance en plusieurs
minutes. AVANT, il relisait toujours les 500 premiers, et les inscrits de la
pause au-delà de ce rang restaient sans séquence. Test :
test/email-sequence-rattrapage.test.js.
L'outil. list_email_sequences accepte detail: true et rend le même bloc
par la même fonction (lib/email-sequences-sante.js, listerDetail).
Le coût. Pas de N+1 : quatre lectures (séquences, listes et abonnés,
étapes, activité) et les deux de listerDetail des serveurs SMTP, quel que
soit le nombre de séquences.
Ce que le verdict ne voit pas : la suppression et le statut de chaque abonné, la délivrabilité (rebonds tardifs, plaintes, SPF/DKIM — voir SMTP), les variables que le rendu laisserait brutes, et une séquence en pause appelée directement par un funnel (le funnel la refuse).
Tests : test/email-sequences-sante.test.js (la table dans l'ordre, chaque
verdict sur une vraie base, le moteur qui programme encore une séquence par
défaut en pause, la partition et l'alerte, pas de N+1, la réponse sans option
inchangée, l'outil).
Worked example : welcome series 3 steps
Recap complet, condensé :
// 1. Create the sequence (becomes the default sequence of list 7)
{ "name": "create_email_sequence", "arguments": { "list_id": 7, "name": "Welcome series" } }
// → returns sequence.id = 12
// 2. Day 0 (5 min after enrollment)
{ "name": "add_email_sequence_step", "arguments": {
"sequence_id": 12, "position": 0,
"delay_minutes": 5,
"subject": "Bienvenue {{first_name}} !",
"body_html": "<p>Voici ton code WELCOME15</p>"
}}
// 3. Day 1 reminder
{ "name": "add_email_sequence_step", "arguments": {
"sequence_id": 12, "position": 1,
"delay_days": 1,
"subject": "Tu n'as pas encore utilisé ton code ?",
"body_html": "<p>Petit rappel — WELCOME15 expire bientôt</p>"
}}
// 4. Day 3 last call
{ "name": "add_email_sequence_step", "arguments": {
"sequence_id": 12, "position": 2,
"delay_days": 3,
"subject": "Dernier rappel : WELCOME15 expire ce soir",
"body_html": "<p>C'est la dernière fois qu'on t'en parle</p>"
}}
// 5. Bind a landing to the list — every form submit will now trigger
// the welcome series automatically
{ "name": "bind_landing_to_email_list", "arguments": { "landing_id": 42, "list_id": 7 } }
// 6. Manually enroll yourself to test the full flow
{ "name": "enroll_email_subscriber", "arguments": {
"list_id": 7,
"email": "moi@example.com",
"name": "Test User",
"custom_fields": { "source_test": "manual" }
}}
5 minutes plus tard, tu reçois le Day 0. Le lendemain à la même heure, le Day 1. 3 jours après l'enrollment, le Day 3. Si STO est activé et que tu as un historique d'opens, chaque mail tombe à ton heure préférée.
Erreurs courantes
- Step créé mais jamais envoyé — vérifie
is_active=truesur le step ET sur la sequence ET sur la liste. Trois flags séparés. Une séquence d'accueil (par défaut) en pause ou supprimée n'inscrit plus personne : les nouveaux abonnés entrent dans la liste sans séquence. À sa réactivation, le passage du moteur (passerRattrapage→scheduleSequenceSteps) les y fait entrer, page par page (500 abonnés par minute, tous atteints) ; leurs délais se comptent depuis ce rattrapage — J0 tout de suite, J+2 deux jours plus tard, jamais tout d'un coup — section « Santé des séquences ». delay_daysinterprété comme "depuis le step précédent" — non : c'est depuis l'enrollment. Pour 3 steps espacés de 1 jour chacun, tu mets0,1,2(pas0,1,1).- Variables non rendues (
{{first_name}}apparaît tel quel) — typo dans le nom de variable, ou subscriber n'a pasname/custom_fieldssetté. Le moteur ne crashe pas, il laisse la chaîne brute. - Plusieurs sequences "default" sur une liste — impossible côté DB :
email_lists.default_sequence_idest un FK simple, un seul ID. Si tu en crées plusieurs, seule la première séquenceactive(chronologiquement) devient default. - Étape
skippedaveccommande_annulee— la commande qui a inscrit l'abonné a été annulée : c'est la garde d'annulation (section « Cible »). Pour parler à ce client, crée une séquenceannuleesur la même liste.
Voir aussi
- Lists — où vivent les sequences
- Éditeur visuel — composer le corps d'un step sans écrire de HTML
- Automations — déclencher l'enrollment automatiquement
- SMTP — sans relais, aucun step ne part
- MCP — tools reference / email — toutes les opérations email en un endroit