La newsletter de ce site tourne sur un serveur que j’ai écrit moi-même : 1 258 lignes de TypeScript, de Svelte et de SQL. Double opt-in, désinscription en un clic, une CLI pour l’envoi, un endpoint de santé. Pas de compte sur une plateforme, pas de facture mensuelle, et la liste des abonnés tient dans un fichier que je copie avec rsync.
Une newsletter, c’est trois endpoints, deux tables, et un en-tête d’e-mail que la plupart des plateformes te cachent.
Cet en-tête, c’est ce qui a rendu le chantier court. En février 2024, Google et Yahoo ont imposé la désinscription en un clic aux expéditeurs en volume, en implémentant la RFC 8058 — les en-têtes List-Unsubscribe et List-Unsubscribe-Post sur chaque e-mail. La partie difficile de l’e-mail marketing est devenue un en-tête standardisé. Tout ce qu’une plateforme payante ajoute autour, c’est soit ça, soit le carnet d’adresses.
Pourquoi auto-héberger une newsletter ?
L’inventaire honnête : la liste est petite, le volume est d’un e-mail par article, et je fais déjà tourner les autres services du site. Louer une plateforme pour ça, c’était confier à un tiers la seule relation d’audience que je possède vraiment, plus un format d’export au nom poli et aux specs floues. L’écrire moi-même a pris un après-midi pour le cœur, un autre pour les bords.
| Ce serveur | Plateforme hébergée | |
|---|---|---|
| Lignes de code que je peux lire | 1 258, toutes | 0 |
| Propriété de la liste | un fichier SQLite | un bouton d’export |
| Désinscription | en-tête RFC 8058, signée maison | réglage du tableau de bord |
| Plafond de délivrabilité | ~5/10 sur mail-tester tant que le DKIM n’est pas aligné | leur réputation |
| Temps avant le premier envoi | un week-end | une soirée |
Le tableau résume l’échange. La plateforme gagne sur la réputation de délivrabilité et sur la paix mentale ; le fichier gagne sur la propriété et la possibilité de tout lire dans un éditeur.
Comment marche le double opt-in sans session ?
Trois fichiers portent tout le flux. db.ts contient deux tables — subscribers, avec une contrainte CHECK qui verrouille status à pending, confirmed ou unsubscribed, et sends pour la piste d’audit. L’endpoint d’inscription prend un POST :
curl -X POST https://mrsaynothing.dev/letters/api/subscribe
-H 'content-type: application/json'
-d '{"email":"[email protected]"}' Avant de regarder ton e-mail, cet endpoint regarde un champ de formulaire nommé website. Les humains ne le voient jamais, donc il reste vide ; les bots remplissent tout, donc un champ rempli leur achète un faux ok et rien d’autre. Passé le honeypot, l’adresse tombe dans un seau à jetons — 10 requêtes par heure et par IP, une relance par adresse toutes les 10 minutes — et les réponses sont volontairement identiques que l’adresse soit déjà inscrite ou non, pour que l’endpoint ne serve jamais à énumérer qui que ce soit.
Le lien de confirmation porte un jeton signé au lieu d’une session :
import { createHmac, timingSafeEqual } from 'node:crypto';
// payload = base64url(email|action), MAC = HMAC-SHA256(payload)
export function sign(email: string, action: 'confirm' | 'unsub'): string {
const payload = Buffer.from(`${email}|${action}`).toString('base64url');
const mac = createHmac('sha256', config.hmacSecret).update(payload).digest('base64url');
return `${payload}.${mac}`;
} La vérification, c’est timingSafeEqual contre un MAC recalculé. Pas de table de jetons, pas d’expiration à gérer — la signature est la seule autorité, donc un lien de confirmation marche même avec la base en plein redémarrage. Les jetons de désinscription n’expirent jamais, exprès : le mécanisme de consentement doit continuer de fonctionner pour toujours, et les révoquer revient à changer la clé de signature, qui vit hors du dépôt et arrive montée en lecture seule à l’exécution.
Un lien de confirmation qui marche quand la base est endormie, c’est une chose de moins qui peut pourrir.
Une bizarrerie des receveurs a dicté la config de sécurité : la désinscription en un clic de Gmail déclenche un POST cross-origin droit sur l’endpoint, donc le contrôle same-origin habituel a dû être désactivé sur cette route — c’est le MAC qui authentifie là-bas, l’en-tête d’origine ne faisait que gêner une norme.
Ce qui a cassé pendant le chantier
Le registre, dans l’ordre :
- Le build de l’image a échoué sur
ERR_PNPM_IGNORED_BUILDS.pnpm install --frozen-lockfilepassait en local et mourait dans le conteneur, parce que le postinstall d’esbuild n’était jamais approuvé. Le fix : embarquer dans l’image le petitpnpm-workspace.yamlqui déclare les scripts de build autorisés. Une demi-journée à trouver, un fichier à corriger. - Le CSRF a bloqué Gmail. Le POST en un clic des serveurs de Gmail n’a évidemment aucun identifiant same-origin. La route fait désormais confiance au jeton, et le jeton est inforgeable.
- La délivrabilité a un plafond sur la voie gratuite. Sans DKIM aligné, mail-tester note le montage autour de 5/10, et les filtres stricts peuvent jeter le courrier. C’est le coût honnête du zéro relais payant ; le fix est connu et attend que la liste le justifie.
Ce qu’il ne sait toujours pas faire
Pas de tracking d’ouverture — celui-là, c’est un principe : le pixel de tracking est la raison pour laquelle les gens se méfient des newsletters. Une seule liste, pas de segments, pas de planification au-delà d’une CLI lancée à la main ou par timer, et le plafond de délivrabilité ci-dessus. L’envoi tient en une commande, cli.mjs send --subject ... --file post.md, avec un flag --dry qui rend le texte et le HTML complets pour relecture avant que quoi que ce soit parte. L’histoire de sauvegarde, c’est celle de SQLite : copier le fichier, restaurer le fichier, et le mode WAL garde la copie cohérente pendant que le serveur tourne. Le démarrage sous supervision systemd fait le reste du boulot d’exploitation.
215 de ces lignes, c’est la CLI. Les 1 043 autres, c’est le consentement.
Quel morceau de ta stack est géré par une plateforme louée, alors que tu préférerais posséder ce travail dans un fichier que tu peux lire ? Et quel abonnement paies-tu encore parce que migrer les données te paraît pire que la facture ?
Abonne-toi à celle de [mrsaynothing.dev/newsletter](/en/newsletter) — elle tourne là-dessus.FAQ
Une newsletter auto-hébergée, ça délivre vraiment ?
Elle passe par du SMTP ordinaire, donc elle subit le même plafond que n'importe quel petit expéditeur : sans DKIM aligné, mail-tester note le montage autour de 5/10 et les filtres stricts peuvent la jeter. Très bien pour une petite liste ; un relais règle le cas des filtres stricts.
Pourquoi node:sqlite plutôt que Postgres ?
Tout l'état tient en deux tables — subscribers et sends. SQLite en mode WAL gère ça sans aucun démon, et une sauvegarde est une copie de fichier. Postgres était la plus grosse dépendance pour le plus petit besoin.
Comment marche la désinscription en un clic ?
Chaque e-mail porte les en-têtes List-Unsubscribe et List-Unsubscribe-Post, définis par la RFC 8058. Gmail et Yahoo l'exigent pour les expéditeurs en volume depuis février 2024 — l'en-tête est toute la fonctionnalité.
— mrsaynothing
— mrsaynothing
Des builds, des casses, et ce qui a vraiment ship.
Discutez de cet article sur dev.to dev.to ↗
Recevez le prochain build log par e-mail
Un e-mail par article. Preuves et erreurs incluses.
qu'est-ce que c'est ?Générateur git undo : la commande exacte pour ton chantier
Ces notes vous plaisent ? Je vis de ce métier. recevoir la newsletter