Le problème : expédier du courrier transactionnel depuis son propre domaine, et savoir ce qu'il devient — pas « délivré à 98 % », mais « refusé chez orange.fr avec le code 550 5.1.1, et plus personne n'ouvre depuis mardi ».
LeTock construit et signe lui-même chaque message : MIME, DKIM avec une clé propre au domaine du client, liste de suppression, chronologie. Aucune API propriétaire dans la chaîne.
LeTock remet lui-même. Il résout le MX du destinataire, ouvre la connexion SMTP vers son serveur, et lit son code de retour. C'est ce qui permet de rendre le texte exact du serveur distant, adresse par adresse — ce qu'aucun webhook de tiers ne donne.
Le port 25 sortant a été débloqué par notre hébergeur le 23 septembre 2026. Le relais qui servait de dernier saut jusque-là est retiré, et l'état relaye n'est plus produit.
## ⚠ Une adresse IP d'envoi neuve doit se chauffer C'est la seule chose qui compte les premières semaines, et elle ne se rattrape pas. Notre adresse d'expédition est neuve : pour les grands fournisseurs de boîtes, elle n'a aucune histoire, et ils traitent l'absence d'histoire comme un risque. Mesure du premier jour, sur deux cent cinquante remises réelles : Google, OVH, IONOS, Outlook et SFR acceptent normalement ; Orange et Wanadoo refusent la connexion (« Adresse IP source bloquée ») ; trois domaines d'entreprise répondent5.7.1 Client host blocked. L'ordre qui marche. Les messages *attendus* d'abord — confirmations d'inscription, reçus, liens de connexion. Ils sont ouverts, rarement signalés, et construisent une réputation propre. Les relances ensuite. La prospection en dernier, une semaine après. Trois cents messages froids par jour depuis une adresse de deux heures est le moyen le plus sûr de la faire classer durablement — et une réputation perdue se reconstruit en mois, pas en jours. Le bac à sable (bac_a_sable: true) ne coûte rien et ne touche pas à la réputation : éprouve tout dessus avant le premier vrai envoi. ### ⚠ Et le pendant, côté retours : ne traite pas ces refus comme des rebonds Les premiers jours, une partie des refus vise notre adresse d'expédition et non tes destinataires — « Client host blocked », « cannot find your reverse hostname », « Adresse IP source bloquée ». Ce sont des refus qui disparaîtront d'eux-mêmes. Si ton code brancherefusetrebond_dursur « retirer l'adresse », il supprimera des adresses parfaitement valides, le premier jour, au moment précis où tu n'as aucune raison de te méfier. C'est arrivé : vingt-cinq entreprises en une heure, dont trois enseignes du cœur de cible et l'adresse personnelle du propriétaire du site. Lis `cause`. Chaque remise et chaque événement le portent :destinataire,expediteuroutransport. Ne retire une adresse que surdestinataire. C'est une seule condition à écrire, et elle rend ce piège impossible.
Les paliers de chauffe
« Monte doucement » ne veut rien dire : personne ne sait si trois cents par jour c'est doucement. Voici les chiffres.
| Jour | Remises recommandées, pour toute la plateforme |
|---|---|
| 1 | 50 |
| 2 | 100 |
| 3 | 200 |
| 4 | 400 |
| 5 | 800 |
| 6 | 1 500 |
| 7 | 2 500 |
| 8 | 4 000 |
| 9 | 6 000 |
| 10 | 8 000 |
| 11 | 12 000 |
| 12 et après | chauffe terminée, le quota du forfait reprend seul |
Le compte est celui des destinataires remis, pas des messages mis en file : c'est ce que voit le serveur d'en face, et c'est lui qui juge. Un message à trois destinataires vaut trois connexions vers trois serveurs. Le bac à sable n'y entre pas.
Le palier est global, et c'est le point qu'aucun guide de chauffe ne traite. Tous les domaines expédient depuis la même adresse IP. Un client qui envoie trois cents messages froids le premier jour ne brûle pas sa réputation : il brûle celle de tout le monde, y compris celle des liens de connexion de la plateforme.
C'est pour ça que le dépassement est signalé à tous, pas seulement à celui qui dépasse. Chacun a le droit de savoir que l'adresse qu'il emprunte est mise sous tension par quelqu'un d'autre.
On reporte, on ne refuse pas. POST /api/v1/mails répond toujours 202. Au-delà du palier du jour, le message est accepté et reporté au lendemain — il part, plus tard, sans perdre de tentative et sans que tu aies à le renvoyer :
```json { "id": "…", "etat": "en_attente", "envoyer_a": "2026-09-24T00:05:00Z", "avertissements": [
"Adresse d'expédition en chauffe, jour 3 : 312 remises aujourd'hui pour 200 recommandées. …" ] }
```
envoyer_a dit quand. Un report tu est un report qu'on découvre en constatant que rien n'est arrivé.
Deux garanties, parce qu'un plafond mal posé casse le mauvais envoi :
- En dessous de 50 remises dans la journée, un domaine n'est jamais reporté, quoi qu'il arrive sur la plateforme. Une confirmation d'inscription, un reçu, un lien de connexion partent tout de suite. Le compte est par domaine et non par équipe : si tu prospectes depuis l'un et que tu envoies des confirmations depuis un autre, c'est celui qui prospecte qui attend. Celui qui remplit l'adresse attend ; les autres passent.
- Le bac à sable n'est jamais reporté : rien n'en sort, donc rien n'y touche la réputation.
Pourquoi ce n'était qu'un avertissement jusqu'au 23 septembre 2026, et pourquoi ça a changé. L'argument tenait : refuser déciderait à ta place, et bloquer une confirmation d'inscription au nom de la réputation serait un dégât immédiat et certain échangé contre un risque différé.
Le premier jour d'expédition directe a mis les deux plateaux à plat. 486 remises pour un palier de 50, dont 297 en une heure depuis un seul domaine. L'adresse d'expédition a été classée dans la foulée — tag « spam:spam-source » — et Orange refuse maintenant la connexion, pour tout le monde, confirmations d'inscription comprises.
Le dégât immédiat et certain n'était donc pas du côté qu'on croyait : il a eu lieu, il touche ceux qui n'ont rien envoyé, et une réputation se reconstruit en mois, pas en jours. Un report d'une nuit, lui, se rattrape le lendemain.
Ce qui compte plus que le volume : l'ordre. Un millier de confirmations d'inscription fait moins de mal que cent messages froids. Ce qui est attendu est ouvert, rarement signalé, et construit une réputation propre ; ce qui ne l'est pas est signalé, et chaque signalement pèse des dizaines de fois son poids.
Et la moitié du module qui ne dépend pas de ça marche aujourd'hui, pour tout le monde : POST /api/mail/{fournisseur}/{jeton} reçoit les webhooks de Resend, Postmark ou SES. Tu continues d'expédier par ton fournisseur, et LeTock surveille la délivrabilité, les rebonds et les plaintes comme s'il expédiait lui-même. Le jeton est sur l'écran Mail, onglet « Webhook entrant ».
Démarrer en 30 secondes
Trois étapes, dans cet ordre, sans possibilité d'en sauter une :
1. Ajouter le domaine au projet et le prouver par DNS (page Veille). Un fichier HTTP ne suffit pas : contrôler le courrier d'un domaine, c'est contrôler son DNS. 2. Préparer le domaine pour l'envoi (page Mail, onglet « Expédier »). Tock attribue une paire de clés et affiche les deux enregistrements TXT à coller, avec leur valeur exacte. Puis « Vérifier » : les deux doivent être vus pour que le domaine passe à « vérifié ». 3. Envoyer.
```bash curl -X POST https://letock.fr/api/v1/mails \ -H "Authorization: Bearer tock_…" \ -H "Content-Type: application/json" \ -d '{
"de": "Boutique <bonjour@monsite.fr>", "a": ["client@exemple.fr"], "sujet": "Ta commande est prête", "texte": "Bonjour, ta commande 42 part demain.", "html": "<p>Bonjour, ta commande 42 part demain.</p>", "bac_a_sable": true
}' ```
bac_a_sable: true fait tout sauf ouvrir la connexion SMTP : validation, liste de suppression, construction MIME, signature DKIM, chronologie, crochets sortants. Rien ne part. C'est la façon de brancher son code sans risquer la réputation du domaine. Retire le champ quand c'est prêt.
Les routes
Toutes les routes /api/v1/** s'authentifient par Authorization: Bearer tock_…. Les routes qui écrivent exigent une clé « lecture et écriture » ; une clé en lecture seule reçoit 403 lecture_seule. Toute réponse acceptée porte les en-têtes de quota.
POST /api/v1/mails
Met un message en file. Répond 202 immédiatement ; le worker expédie dans la minute (ou à la date de envoyer_a).
| champ | type | rôle |
|---|---|---|
de | texte, requis | l'expéditeur, Nom <adresse> accepté. Doit être sur un domaine du projet, prouvé et préparé |
a | texte ou liste, requis | les destinataires, 50 au plus |
sujet | texte, requis | 200 caractères au plus |
texte | texte | la partie texte brut |
html | texte | la partie HTML. Au moins l'une des deux est requise ; ensemble elles font 200 Ko au plus |
repondre_a | texte | l'adresse de réponse |
idempotence | texte | la même valeur deux fois ne produit pas deux envois |
suivi_clics | booléen | réécrire les liens pour compter les clics. Absent : le réglage du domaine décide |
desabonnement | booléen | porter l'en-tête de désabonnement en un clic. Vrai par défaut |
envoyer_a | texte ISO 8601 | expédier à partir de cette date |
bac_a_sable | booléen | tout jouer sans rien remettre |
pieces_jointes | liste | les fichiers joints : { nom, type, contenu_base64 }. 10 au plus, 10 Mo bruts au total |
Les pièces jointes se posent dans pieces_jointes, une liste de { nom, type, contenu_base64 } où le contenu est le fichier en base64 standard — pas de préfixe data:, pas de variante URL. type est facultatif et vaut application/octet-stream sans lui.
```json { "de": "Facturation <facture@monsite.fr>", "a": "client@exemple.fr", "sujet": "Votre facture", "texte": "Bonjour, votre facture est jointe.", "pieces_jointes": [
{ "nom": "facture-2026-04.pdf", "type": "application/pdf", "contenu_base64": "JVBERi0xLjQ..." }] } ```
Trois limites, et le corps de la requête est plafonné à 16 Mo sur cette route alors que le reste de l'API s'arrête à 64 ko : le base64 gonfle d'un tiers, donc 10 Mo de fichiers en pèsent 13,4 une fois encodés.
- 10 fichiers au plus, 10 Mo bruts au total — le cumul, pas l'unité.
- Les exécutables sont refusés (
.exe,.js,.bat,.msi, une quarantaine d'extensions). Ce n'est pas un antivirus : c'est la liste que Gmail et Outlook rejettent déjà, et leur refus emporte le message entier, facture comprise. Le nôtre arrive à l'appel, où il s'explique. - Le refus est immédiat. Un fichier trop lourd n'est jamais mis en file : une
202suivie d'un message qui ne part pas serait pire qu'une erreur.
L'idempotence couvre les fichiers. Une clé réutilisée renvoie le message d'origine — c'est ce qu'on attend d'un réessai après une coupure. Mais si les pièces jointes ont changé, ce n'est pas un réessai : tu croirais avoir envoyé la facture B alors que c'est la A qui part, chez un destinataire qui n'a rien demandé. Ce cas répond 409 idempotence_divergente au lieu de se taire. Une clé par document — le numéro de facture en fait une très bonne.
Réponse 202 : { "id", "etat": "en_attente", "nouveau", "ecartes": [{ "adresse", "motif" }] }. nouveau: false veut dire que l'idempotence a retrouvé un envoi existant. ecartes liste les destinataires retirés par la liste de suppression — ils ne sont jamais réessayés, et l'appelant l'apprend au lieu de croire que c'est parti.
Erreurs : 400 champs_manquants, 400 de_invalide, 400 a_invalide, 400 a_trop, 400 sujet_vide, 400 sujet_long, 400 corps_vide, 400 corps_long, 400 envoyer_a_invalide, 403 domaine_inconnu, 403 domaine_non_pret, 409 destinataires_supprimes, 429 quota, 422 piece_jointe_invalide, 422 piece_jointe_refusee, 413 pieces_jointes_trop_lourdes, 409 idempotence_divergente.
POST /api/v1/mails/lot
Un appel, jusqu'à 500 messages distincts, chacun avec son propre rendu. Ce n'est pas un message à 500 destinataires : c'est ce qui rend l'ouverture attribuable et le désabonnement individuel.
```bash curl -X POST https://letock.fr/api/v1/mails/lot \ -H "Authorization: Bearer tock_…" -H "Content-Type: application/json" \ -d '{
"de": "Boutique <bonjour@monsite.fr>",
"modele": "bienvenue",
"destinataires": [
{ "a": "lea@exemple.fr", "variables": { "prenom": "Léa", "code": "A1" } },
{ "a": "max@exemple.fr", "variables": { "prenom": "Max", "code": "B2" } }
]}' ```
modele cite un modèle enregistré ; à défaut, donner sujet et texte/html en clair — les deux formes acceptent les variables {{nom}}. La variable email est toujours disponible sans être fournie. Chaque entrée de destinataires peut être une simple adresse ou un objet { a, variables }.
Réponse 202 : { "lot", "mis", "ids": [...], "bac_a_sable", "ecartes", "doublons" }. Les doublons d'adresse sont retirés silencieusement et listés : la même adresse deux fois n'est pas deux envois.
Erreurs en plus des précédentes : 400 lot_trop_grand, 400 destinataire_invalide, 400 modele_inconnu, 400 variables_manquantes — cette dernière porte dans aide la liste des variables sans valeur, et rien n'a été débité : le rendu se fait avant le quota.
GET /api/v1/mails/modeles
Rend la bibliothèque : { "modeles": [{ "cle", "nom", "sujet", "variables", "a_du_texte", "a_du_html", "maj_le" }] }. Lecture seule acceptée.
POST ou PUT /api/v1/mails/modeles
Crée ou remplace, indifféremment : un modèle est identifié par sa cle, que l'appelant choisit. Corps : cle (minuscules, sans espace), sujet, nom, texte, html, exemple (un objet de valeurs pour la prévisualisation). Réponse : la clé, les variables relevées, sans_exemple (celles qu'aucun exemple ne couvre) et apercu, le rendu avec les exemples. Erreurs : 400 cle_invalide, 400 sujet_vide, 400 sujet_long, 400 corps_vide, 400 corps_long, 400 trop_de_modeles.
DELETE /api/v1/mails/modeles?cle=bienvenue
Réponse { "supprime": "bienvenue" }, ou 404 modele_inconnu.
POST /api/mail/{fournisseur}/{jeton}
Reçoit les webhooks d'un fournisseur tiers quand le client expédie par ailleurs — resend, postmark, ses. Le jeton est propre à l'équipe et se renouvelle depuis l'écran. Sert à alimenter la délivrabilité sans changer d'expéditeur.
Recevoir
LeTock reçoit désormais du courrier. C'est la moitié qui manquait, et elle ne dépend pas du port 25 sortant : le port 25 entrant n'a jamais été bloqué.
Une boîte se déclare, elle ne s'improvise pas. Tant qu'une adresse n'est pas déclarée, elle est refusée en 550 — franchement, à l'expéditeur, qui saura donc que son message n'est pas passé. Accepter puis jeter serait pire que refuser : c'est exactement ce qui fait écrire dans le vide.
Le serveur ne relaie jamais. Aucun destinataire hors des domaines déclarés n'est accepté, sous aucune condition : un serveur qui relaie pour des inconnus devient en quelques heures une machine à indésirables, et son adresse IP est brûlée pour des mois — la même que celle qui sert le site.
Il ne filtre pas non plus les indésirables. Ce serait un autre métier, et un filtre approximatif qui jette est pire que pas de filtre : le message est perdu et l'expéditeur croit l'avoir remis.
Ce qu'il faut poser : un MX vers notre serveur. Il apparaît dans « Mise en route » et dans GET /api/v1/domaines/dns dès qu'une boîte est déclarée sur le domaine — et pas avant : suggérer un MX à un domaine dont le courrier arrive ailleurs serait le pire conseil possible, il couperait sa réception.
GET /api/v1/mails/recus?depuis=<ISO>&boite=&limite=&brut=1
Ce qui est arrivé. Lecture seule acceptée.
```json { "jusqua": "2026-09-23T09:40:00Z", "messages": [
{ "id": "…", "boite": "contact@monsite.fr",
"de": "Camille <camille@exemple.fr>",
"enveloppe_de": "camille@exemple.fr",
"sujet": "Une question", "ip": "203.0.113.4",
"octets": 4218, "recu_le": "…", "lu_le": null } ] }```
`de` et `enveloppe_de` sont rendus tous les deux, et jamais l'un sans l'autre. Le premier vient du From: et se forge en une ligne ; le second vient du MAIL FROM du dialogue SMTP et se constate. Leur désaccord est en soi une information : c'est la forme la plus banale d'une usurpation.
brut=1 ajoute le message tel qu'il est arrivé — en-têtes compris. Il n'est pas rendu par défaut : il pèse jusqu'à 25 Mo, et personne ne lit 25 Mo d'en-têtes pour savoir qui a écrit. C'est pourtant lui qui fait foi, et la seule forme qui permette de vérifier une signature DKIM après coup.
Erreur : 400 depuis_invalide.
La boîte : lire, répondre, écrire
letock.fr/boite réunit le courrier de chaque domaine — envoyé et reçu dans le même fil. C'est une application à part, plein écran, installable : elle se met sur un écran d'accueil comme celles du système.
Ce qu'elle a et qu'aucune boîte grand public n'a : sous chaque message parti, ce qu'en a dit le serveur du destinataire — le code SMTP et son texte exact, adresse par adresse. Un webmail ordinaire affiche « Envoyé » et s'arrête là, parce qu'aucun ne remet lui-même.
Les fils se rangent tout seuls, et chaque état dit pourquoi il a été posé :
| État | Quand | Ce qu'il dit |
|---|---|---|
| À répondre | le dernier mot est à eux | « Reçu aujourd'hui, et personne n'a répondu depuis. » |
| Traité | ils ont écrit, tu as répondu | « Rien ne t'attend de ce côté. » |
| En attente | tu as écrit le premier | « Parti il y a 2 jours. La balle est chez eux. » |
| À relancer | …et rien après 5 jours | « Parti il y a 5 jours, et rien n'est revenu. » |
| Sans suite | l'expéditeur dit qu'on ne lui répond pas | en-tête Auto-Submitted, Precedence: bulk ou List-Unsubscribe |
Rien n'est deviné : le dernier cas lit une déclaration de l'expéditeur, pas une intuition. Un classement qui se trompe sans dire pourquoi est pire qu'une liste à plat — on cesse de s'y fier sans pouvoir le corriger. Et la main gagne toujours, avec le bouton qu'on oublie partout : « laisser LeTock décider », qui rend le fil à la règle.
« Toujours faire confiance à cet expéditeur » garde le choix pour une ADRESSE, jamais pour un domaine — faire confiance à « @gmail.com » reviendrait à faire confiance à la terre entière. La confiance appartient à l'équipe, parce qu'une adresse partagée se lit à plusieurs, et elle se retire d'un clic. Ce qu'elle ouvre, et rien d'autre : le chargement des images, c'est-à-dire d'admettre que cet expéditeur sache quand on ouvre son courrier. Le HTML reste assaini, le cadre reste sans origine, les scripts restent refusés.
Le corps d'un message s'affiche dans un cadre isolé, sans origine et sans script : le HTML vient d'inconnus, et la page où on le lit est connectée à ton compte. Les images distantes sont bloquées par défaut et comptées — les charger dirait à l'expéditeur que le message a été ouvert, quand, et depuis quelle adresse IP. C'est le pixel que ce module sait poser lui-même ; nous savons donc ce que ça vaut.
Le dernier saut est chiffré, et voici ce que ça garantit
Le serveur d'entrée propose STARTTLS. Un expéditeur qui sait chiffrer chiffre ; les autres continuent d'être acceptés en clair, parce que les refuser reviendrait à perdre leur courrier.
Il faut dire exactement ce que ça change. Entre serveurs de courrier, TLS est opportuniste : l'expéditeur chiffre s'il peut, et dans l'immense majorité des cas il ne vérifie pas notre certificat. Ce qu'on gagne est donc le chiffrement du dernier saut — la protection contre l'écoute passive. Ce qu'on ne gagne pas est l'authentification : un attaquant capable de détourner le trafic peut encore se faire passer pour nous. Ce qui fermerait cette seconde moitié est MTA-STS ou DANE, et ça se pose dans le DNS.
Le dire compte : annoncer « chiffré » en laissant croire à une garantie d'identité serait une promesse fausse, et sur ce sujet-là une promesse fausse vaut moins que rien.
Après la poignée de main, tout repart de zéro — c'est la RFC 3207, et ce n'est pas une formalité : ce qui a été dit en clair avant peut avoir été injecté par un intermédiaire.
Ce que la réception compte, et ce qu'elle jette
Le nombre d'adresses n'est pas un quota et ne le sera pas. Une adresse n'est pas un siège : ni mot de passe, ni récupération de compte, ni support par personne — c'est une ligne dans une table et une règle de routage. Les hébergeurs qui facturent « à la boîte » facturent un compte complet et son stockage ; la preuve tient dans leur propre produit, où les alias sont gratuits et illimités.
Ce qui est compté, c'est le volume reçu : receptions, par mois, comme les envois. Il apparaît dans la réponse de quota comme les autres postes.
Un dépassement ne perd rien. Le serveur répond 452, qui est temporaire : le serveur d'en face garde le message et le représente pendant plusieurs jours — le temps de s'en apercevoir, de changer de forfait, ou de laisser le mois suivant arriver. Un 550 aurait été définitif, avec un rapport de non-remise chez l'expéditeur.
Le message d'origine est jeté à quatre-vingt-dix jours. C'est lui qui fait foi et qui permet de vérifier une signature DKIM après coup — mais une preuve a une durée utile, et la garder pour toujours reviendrait à payer éternellement la preuve d'un message que plus personne ne contestera. Le corps décodé, lui, est écrit à la réception et suit la rétention de ton forfait : le message reste lisible, et l'écran dit ce qui est parti plutôt que de laisser croire qu'il n'a jamais existé.
brut=1 sur GET /api/v1/mails/recus rend donc le message d'origine tant qu'il existe. Au-delà, le champ est absent et brut_purge_le dit quand il a été jeté — on ne déduit pas l'état d'une donnée de sa nullité.
Une adresse sur ton propre domaine
contact@ton-domaine.fr qui reçoit vraiment, créé en dix secondes, depuis l'écran Boîte › Adresses ou par l'API. Deux conditions, et l'écran dit laquelle manque :
1. Le domaine est prouvé par DNS. Sans cette preuve, n'importe qui déclarerait « facturation@ » sur le domaine d'un autre et lirait son courrier. C'est la même exigence que pour l'envoi, et pour une raison plus forte : recevoir, c'est lire. 2. Son MX pointe vers nous. C'est la seule chose que nous ne pouvons pas faire à ta place. La valeur exacte est rendue par l'API et affichée à l'écran, avec ce que ton domaine répond aujourd'hui à côté — remplacer un MX existant ferait cesser d'arriver ce qui arrive, et ça ne laisse aucune trace.
GET /api/v1/mails/boites?domaine=
Les adresses, ce qu'elles ont reçu, et recoit : lu dans le DNS, pas dans un drapeau en base. Un enregistrement se retire par mégarde, et une adresse qui s'annonce prête pendant que son MX a disparu fait chercher partout sauf au bon endroit.
POST /api/v1/mails/boites
``json { "domaine": "monsite.fr", "boite": "contact" } ``
Écris boite sans l'arobase. La réponse porte l'adresse complète, et l'enregistrement MX à poser s'il manque encore — taire ce qui reste à faire donnerait une adresse qui a l'air créée et ne reçoit rien, c'est-à-dire exactement l'écriture dans le vide que cette réception existe pour empêcher.
{ "fermer": true } referme une adresse sans effacer ce qu'elle a reçu : le courrier déjà arrivé appartient à celui qui l'a reçu. Refermée, elle est de nouveau refusée en 550 — donc celui qui écrit le sait.
Ouvre `postmaster` et `abuse`. La norme (RFC 5321) exige la première de tout domaine qui reçoit, et c'est l'adresse qu'un administrateur distant essaie quand tout le reste échoue ; la seconde est celle que donnent les annuaires en cas de problème. Ne pas les avoir, c'est être injoignable exactement le jour où ça compte.
Mission requise : `mail.boites`, distincte de mail.envoyer. Un agent qui envoie des reçus n'a aucune raison de pouvoir déclarer une adresse de réception — et l'inverse non plus. La distinction n'est pas de la forme : ouvrir une adresse, c'est ouvrir une porte par laquelle du courrier arrivera, et tout ce qui y arrive devient lisible par toute clé de lecture de l'équipe.
Erreurs : 409 preuve_dns_requise, 422 boite_invalide, 422 boites_trop, 403 mission_non_autorisee.
GET /api/v1/mails/budget
À lire AVANT d'envoyer en boucle. avertissements arrive *après* l'envoi : une boucle ne le voit qu'une fois le mal fait.
```json { "chauffe": { "jour": 1, "palier_du_jour": 50, "remises_aujourdhui": 486,
"restant": 0, "depasse": true, "plancher_par_domaine": 50 },
"envois": { "restant": 14500 }, "quoi_faire": "Le palier du jour est atteint. …" } ```
Tu peux compter ton propre volume chez toi. Ce que tu ne peux pas savoir, c'est ce que les autres ont déjà consommé de l'adresse partagée — et c'est ce chiffre-là qui décide. La route ne dit pas qui a consommé quoi : restant suffit, et ne dit rien de personne.
Deux limites, qui n'ont rien à voir : le palier protège la réputation d'une adresse partagée et reporte au lendemain ; le quota d'envois mesure ce que tu paies et refuse. Les confondre fait chercher la mauvaise cause.
Cette route existe parce qu'un client a décrit sa propre faute : une fonction de relances avec son lot de trois cents, qui ne consultait pas le plafond du jour et l'a rempli pendant que le calcul disait vingt-cinq. 486 remises pour un palier de 50, l'adresse classée dans la foulée, et les confirmations d'inscription de tout le monde refusées par Orange.
Pourquoi tu n'as aucune ouverture
Si suivi_clics vaut false sur tes envois — c'est un champ de POST /api/v1/mails, et il vaut ce que tu y mets —, aucun pixel n'est posé et aucun lien n'est réécrit. Les événements ouvert et clique ne peuvent alors pas exister.
Ce n'est pas zéro ouverture : c'est aucune mesure. GET /api/v1/mails/{id} rend donc suivi_clics et le dit en toutes lettres quand il est coupé — un zéro sans explication se lit « la fonction n'existe pas », et c'est exactement le faux vert que ce module reproche aux autres.
GET /api/v1/mails/{id}
Ce qu'un message est devenu. Lecture seule acceptée.
```json { "id": "b0d497cd-…", "etat": "envoye", "envoye_le": "2026-09-22T18:10:03Z", "remises": [
{ "adresse": "client@orange.fr", "etat": "rebond_dur", "cause": "destinataire",
"code": 550, "code_etendu": "5.1.1", "message": "user unknown",
"serveur": "mx1.orange.fr" }], "chronologie": [ { "etape": "file", "quand": "…" }, { "etape": "remise", "quand": "…" } ] } ```
C'est la route qui permet de basculer sans risque, et son absence arrêtait net : un client a envoyé un message d'épreuve avant de faire passer ses relances de production, ne l'a pas vu arriver, et ne pouvait pas trancher entre quatre cas — encore en file, différé par le serveur distant, refusé, ou remis puis perdu par sa propre réception. Trois ne sont pas un problème, un seul est grave. Il n'a pas basculé.
Le code SMTP et le texte exact du serveur distant figurent adresse par adresse. C'est ce qu'aucun webhook de tiers ne donne : « délivré à 98 % » ne dit pas lequel a été refusé, ni pourquoi.
##### cause : à qui la faute. Ne supprime jamais sur `expediteur`.
Trois valeurs, et c'est le champ qui décide si une adresse peut être retirée d'une liste :
cause | Ce que ça veut dire | Ce qu'il faut faire |
|---|---|---|
destinataire | L'adresse ou son domaine sont en cause. | Un rebond_dur se retire. |
expediteur | Nous sommes en cause : adresse d'expédition classée, nom inverse absent, réputation. L'adresse visée est bonne. | Ne retire rien. Réessaie plus tard. |
transport | Ni l'un ni l'autre : un incident de réseau. | Rien à faire, la remise est rejouée. |
Ce champ existe parce que son absence a coûté une liste. Un client a retiré définitivement vingt-cinq contacts sur la foi de nos événements — trois enseignes de son cœur de cible, six adresses Orange, et sa propre adresse personnelle, qui n'aurait plus rien reçu de son propre système. Aucun des vingt-cinq n'était en cause : les refus disaient « Client host blocked » et « cannot find your reverse hostname », c'est-à-dire notre adresse d'expédition, neuve et sans histoire.
Il n'avait pas tort de les retirer : avec l'information qu'on lui donnait, c'était le geste raisonnable. C'est l'information qui manquait.
L'asymétrie vaut d'être retenue, parce qu'elle vaut au-delà de ce champ : garder une adresse morte coûte un peu de réputation à chaque envoi, et se corrige au rebond suivant. Retirer une adresse vivante ne se corrige pas — la personne ne reçoit plus rien, et personne ne le lui dit.
LeTock applique la même règle pour sa propre liste de suppression : un rebond_dur dont la cause est expediteur ne supprime rien.
Erreur : 404 message_inconnu.
GET /api/v1/mails/evenements?depuis=<ISO>&type=&limite=
Le flux de ce que deviennent tes messages — file, remise, accepte, differe, rebond_dur, rebond_doux, refus, ouvert, clique, plainte, desabonnement, supprime, echec. Lecture seule acceptée.
Chaque événement porte cause — destinataire, expediteur ou transport —, avec la règle ci-dessus : sur `expediteur`, ne retire personne de ta liste. Le refus parle de notre adresse d'expédition, pas de la sienne.
Rendu dans l'ordre croissant, avec jusqua à redonner en depuis au prochain appel, et encore: true quand la page est pleine — il en reste, rappelle tout de suite. La date vient du serveur : une horloge de client en avance ferait sauter des événements sans que personne s'en aperçoive.
C'est l'alternative aux crochets sortants pour qui ne tient pas de serveur joignable : aucune signature à concevoir, aucune remise à garantir, tu demandes à ton rythme. Les deux coexistent.
Erreurs : 400 depuis_invalide, 400 type_inconnu.
GET et POST /api/v1/mails/domaines
Le GET rend, pour chaque domaine de l'équipe, pret et l'étape qui manque : preuve_dns, preparation, enregistrements ou pret. Lecture seule acceptée.
Le POST relit le DNS à la demande — {"domaine": "monsite.fr"} — et rend chaque enregistrement avec son état. Ajoute {"preparer": true} pour attribuer la paire de clés si le domaine n'a jamais été préparé.
Pourquoi les deux existent. Un agent qui voit 403 domaine_non_pret ne pouvait que répéter « la page Mail le dit » à la personne. Avec ça, il dit « ton SPF est bon, ton DKIM est publié sur le mauvais sélecteur » — et la personne ouvre son registrar une fois au lieu de trois. Relire deux TXT est l'opération la moins destructive qui soit : elle ne crée rien, ne supprime rien, et son résultat ne dépend que du DNS public. Elle n'avait aucune raison d'être réservée à un bouton.
Sans preparer, l'appel ne fait que relire : c'est ce qu'on réessaie en boucle pendant une propagation, et fabriquer une paire de clés à chaque tentative invaliderait le DKIM déjà publié.
Erreurs : 400 champs_manquants, 404 domaine_inconnu, 409 preuve_dns_requise, 409 domaine_non_prepare.
GET /api/mail/o/{id}.gif
Le pixel d'ouverture. Publique, sans cache. Rend toujours un GIF, même pour un identifiant inconnu.
GET /api/mail/c/{id}/{n}
Le clic : note, puis redirige vers la cible d'origine. Si la mesure échoue, la redirection a lieu quand même — c'est le seul endroit du module où perdre une mesure est le bon choix.
GET et POST /api/mail/d/{id}
Le désabonnement en un clic (RFC 8058). `POST` désabonne, `GET` non : les antivirus et les proxys visitent les liens d'un message, et un désabonnement sur GET se déclencherait tout seul. Le GET rend une page avec un bouton.
GET /mail/export?q=&etat=
Session, pas clé d'API. CSV, point-virgule et BOM, une ligne par destinataire et non par message : « ces onze adresses chez orange.fr ont été refusées » se colle dans un tableur, « trois messages ont échoué » ne se rapproche de rien. 5 000 lignes au plus.
Les crochets sortants
Tock appelle le serveur du client à chaque événement. Cinq crochets au plus par équipe, déclarés depuis l'écran Mail, chacun abonné à ce qu'il veut.
Émis aujourd'hui, par les messages que Tock expédie lui-même : livre, differe, rebond_dur, rebond_doux, refus (au moment de la remise, un par destinataire), ouvert, clique, desabonnement.
Déclarable mais jamais émis pour l'instant : plainte. Une plainte pour indésirable n'arrive que par le webhook d'un fournisseur tiers, qui ne porte aucun identifiant de message côté Tock — il n'y a donc rien à rattacher. C'est écrit ici plutôt que laissé découvrir : s'abonner à plainte et n'en recevoir aucune ne veut pas dire qu'il n'y en a pas.
Et ce que ça coûte n'est pas le même selon d'où tu viens. Si tu ajoutes LeTock à côté d'un fournisseur tiers, tu gardes /api/mail/<fournisseur>/<jeton> et les plaintes continuent d'arriver par lui : rien n'est perdu. Si tu remplaces ton fournisseur par LeTock — ce que l'écran Mail te propose de faire — tu perds la boucle de retour et tu ne peux pas la remplacer, puisqu'il n'y a plus de tiers pour l'alimenter.
Prévois-le si tu fais de la prospection : un garde-fou qui lit un taux de signalements verra zéro, quoi qu'il arrive. Ce n'est pas une mesure basse, c'est une absence de mesure — et un seuil, lui, ne fait pas la différence. Il conclut « tout va bien, on peut monter en volume » exactement dans le cas où il faudrait s'arrêter.
Le taux de rebond, lui, reste juste : rebond_dur et refus arrivent bien. C'est la moitié du garde-fou qui est aveugle, pas tout.
Charge utile :
``json { "evenement": "rebond_dur", "message_id": "…", "adresse": "client@exemple.fr", "detail": "550 5.1.1 user unknown", "code": 550, "cause": "destinataire", "quand": "2026-03-17T10:00:02Z" } ``
cause vaut destinataire, expediteur ou transport. Le code qui reçoit ce crochet ne doit retirer une adresse que sur `destinataire`. Sur expediteur, le refus parle de notre adresse d'expédition — la personne visée n'y est pour rien, et la retirer la priverait de courrier pour une panne qui ne lui appartient pas. Vingt-cinq contacts ont été perdus ainsi avant que ce champ existe, faute de pouvoir faire la différence.
En-tête tock-signature: t=<horodatage>,v1=<hex> — HMAC-SHA256 de "<t>.<corps brut>" avec le secret du crochet. La vérification côté client, en Node :
``js const [t, v1] = entete.split(',').map((p) => p.split('=')[1]) const attendue = crypto.createHmac('sha256', SECRET).update(${t}.${corpsBrut}).digest('hex') const bon = crypto.timingSafeEqual(Buffer.from(attendue, 'hex'), Buffer.from(v1, 'hex')) && Math.abs(Date.now() / 1000 - Number(t)) < 300 ``
L'horodatage entre dans la signature : un appel capté ne peut pas être rejoué plus tard. Comparer avec timingSafeEqual et non === — comparer caractère par caractère laisse mesurer combien de caractères sont bons.
Un appel est réussi sur 2xx seulement ; un 3xx ne compte pas. Six tentatives, écart doublé : 1, 2, 4, 8, 16, 32 minutes. Après vingt échecs consécutifs, le crochet se tait et l'écran affiche le compteur — il se réveille d'un bouton.
Les écrans
/mail, avec ses onglets, chacun une URL :
?onglet=(défaut) — la délivrabilité par domaine : la semaine, la courbe de trente jours, et ce qui est parti sans être lu.?onglet=destinations— la délivrabilité par domaine destinataire. C'est là que se voit le faux vert : 98 % en moyenne, 40 % de rejets chez un seul fournisseur.?onglet=envois— la liste des envois, avec recherche et filtre d'état. Chaque ligne mène à/mail/message/{id}: la chronologie complète et la remise adresse par adresse, avec le code SMTP et le texte exact du serveur.?onglet=modeles— la bibliothèque de modèles, avec prévisualisation.?onglet=suppressions— la liste de suppression, avec réhabilitation.?onglet=expedition— les domaines d'envoi, les enregistrements à poser, le suivi des clics et le bac à sable.?onglet=crochets— les crochets sortants et leur journal d'appels.?onglet=webhook— l'adresse de réception des webhooks de fournisseurs tiers.
Les quotas
- Poste `envois` : consommé par destinataire gardé, à la mise en file, pour les envois unitaires comme pour les lots. Un destinataire écarté par la liste de suppression n'est pas facturé. Un envoi en bac à sable l'est : il écrit les mêmes lignes.
- Poste `alertes` : les messages que Tock envoie au client (dérive de délivrabilité, enregistrement disparu). Les crochets sortants ne le consomment pas.
- Garde-fou indépendant du forfait : 1 000 messages par jour et par équipe, pour qu'un compte compromis ne vide pas son mois en une nuit. Les deux se cumulent, le plus serré gagne.
- Rétention :
retentionJoursdu forfait pour les envois ; 30 jours pour le journal des crochets.
Les pièges
- Une pièce jointe n'est pas un lien. Elle voyage dans le message, compte dans le poids que le serveur d'en face accepte, et reste dans la boîte du destinataire pour toujours. Au-delà de quelques mégaoctets, un lien vers un fichier hébergé passe partout et se révoque ; une pièce jointe, non.
- Un domaine prouvé par HTTP ne peut pas envoyer. Seule la preuve DNS est acceptée, et le message d'erreur le dit.
- La propagation DNS n'est pas instantanée. « Les enregistrements ne sont pas encore tous visibles » après avoir collé les bons enregistrements veut souvent dire « attends une heure », pas « c'est faux ».
- Un domaine qui n'existe pas est un rebond dur, pas un différé. Si le domaine du destinataire n'a ni MX ni adresse — une faute de frappe dans une liste, un site fermé —, aucune attente ne le fera apparaître. L'adresse part en liste de suppression au premier essai plutôt qu'après cinq. En revanche, un serveur qui existe et ne répond pas reste un différé : il répondra peut-être dans une heure, et supprimer l'adresse la priverait de tout courrier futur pour une panne qui ne lui appartient pas. Un résolveur DNS temporairement muet ne supprime rien non plus — c'est la différence entre « ce nom n'existe pas » et « je n'ai pas pu demander ».
- Une adresse en liste de suppression n'est jamais réessayée, y compris par un renvoi. C'est volontaire : insister sur une adresse qui vient de rebondir durement est exactement le geste qui détruit une réputation d'expédition. Réhabiliter se fait à la main, sur l'écran.
- Un désabonnement ne bloque que les messages qui portaient eux-mêmes le lien de désabonnement. Se désinscrire d'une lettre d'information n'empêche pas de recevoir le reçu de sa commande — c'est le piège des listes de suppression uniques.
- Le bac à sable ne nourrit ni la délivrabilité ni la liste de suppression. Une courbe de réputation gonflée par des essais serait fausse, et c'est la courbe sur laquelle on décide d'agir.
- Un domaine déclaré en bac à sable l'impose au message :
bac_a_sable: falsedans l'appel ne le contredit pas. L'inverse serait un piège à une lettre près. - Le lot n'accepte pas `idempotence`. Rejouer un lot fait des doublons chez ceux qui avaient déjà reçu ; c'est à l'appelant de tenir son propre garde-fou.
- L'ouverture n'est attribuable qu'à un seul destinataire. Un message part une fois pour tous ses destinataires : le pixel et les liens sont les mêmes. Quand il y en a plusieurs, l'ouverture est notée sans adresse. Le lot n'a pas ce problème.
- Le suivi des clics réécrit les liens, donc l'adresse affichée au survol n'est plus celle du site. Réglage par domaine, que le message peut contredire dans les deux sens.
- Une variable de modèle sans valeur refuse l'envoi au lieu de la remplacer par une chaîne vide. « Bonjour , ta commande est prête » part sinon, arrive, et se lit comme un bug du client chez son propre client.
- Les valeurs de variables sont échappées dans le HTML, pas dans le texte. Un nom de société avec une esperluette ne casse pas le rendu.
- Le contrôle avant envoi n'est pas un score anti-spam. Il ne rend que des faits de forme vérifiables (message sans partie texte, sujet en capitales, contenu tout en image, lien vers un raccourcisseur). Aucun outil ne peut prédire honnêtement le classement de Gmail, qui dépend surtout de la réputation de l'expéditeur.
SEO & LLM
Le module répond à une question que ni Google Search Console, ni Ahrefs, ni Screaming Frog ne traitent : est-ce que les moteurs et les assistants IA peuvent encore lire ce site, et est-ce qu'ils l'envoient encore des visiteurs ? Il relit les pages sans exécuter le JavaScript — comme le fait un robot d'assistant —, il demande la page sous l'identité de GPTBot, ClaudeBot et PerplexityBot pour voir ce que le serveur leur répond vraiment, et il compare chaque source d'arrivée aux mêmes jours des semaines passées.