Volver al blog

Mi servidor de newsletter son 1.258 líneas de node:sqlite

26 de septiembre de 2026

La newsletter de este sitio corre en un servidor que escribí yo: 1.258 líneas de TypeScript, Svelte y SQL. Doble opt-in, baja en un clic, una CLI para enviar, un endpoint de salud. Sin cuenta en ninguna plataforma, sin factura mensual, y la lista de suscriptores vive en un archivo que puedo copiar con rsync.

Una newsletter son tres endpoints, dos tablas y un encabezado de correo que la mayoría de las plataformas te esconden.

Ese encabezado es lo que acortó la obra. En febrero de 2024, Google y Yahoo convirtieron la baja en un clic en requisito para remitentes con volumen, implementando la RFC 8058 — los encabezados List-Unsubscribe y List-Unsubscribe-Post en cada correo. La parte difícil del email marketing se volvió un encabezado estandarizado. Todo lo que una plataforma de pago agrega alrededor es eso, o la libreta de direcciones.

¿Para qué autohospedar una newsletter?

El inventario honesto: la lista es chica, el volumen es un correo por post, y ya tengo corriendo los demás servicios del sitio. Rentar una plataforma para eso significaba entregarle a un tercero la única relación de audiencia que de verdad poseo, más un formato de exportación con nombre amable y especificación difusa. Escribirla me tomó una tarde para el núcleo y otra para los bordes.

Este servidorPlataforma hospedada
Líneas de código que puedo leer1.258, todas0
Dueño de la listaun archivo SQLitebotón de exportar
Bajaencabezado RFC 8058, firmado en casaajuste del panel
Techo de entregabilidad~5/10 en mail-tester hasta alinear DKIMsu reputación
Tiempo hasta el primer envíoun fin de semanauna noche

La tabla es el trato en una pantalla. La plataforma gana en reputación de entregabilidad y en no tener que pensar; el archivo gana en propiedad y en poder auditarlo en un editor.

¿Cómo funciona el doble opt-in sin sesión?

Tres archivos cargan todo el flujo. db.ts tiene dos tablas — subscribers, con una restricción CHECK que fija status en pending, confirmed o unsubscribed, y sends para la pista de auditoría. El endpoint de suscripción recibe un POST:

curl -X POST https://mrsaynothing.dev/letters/api/subscribe 
  -H 'content-type: application/json' 
  -d '{"email":"[email protected]"}'

Antes de mirar tu correo, ese endpoint mira un campo del formulario llamado website. Los humanos nunca lo ven, así que queda vacío; los bots llenan todo, así que un campo lleno les compra un ok falso y nada más. Pasado el honeypot, la dirección cae en un balde de tokens — 10 requests por hora por IP, un reenvío por dirección cada 10 minutos — y las respuestas son idénticas a propósito, esté o no suscrita la dirección, para que nadie pueda usar el endpoint para enumerar gente.

El enlace de confirmación lleva un token firmado en lugar de una sesión:

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 verificación es timingSafeEqual contra un MAC recalculado. No hay tabla de tokens ni expiración que administrar — la firma es la única autoridad, así que un enlace de confirmación funciona hasta con la base reiniciándose. Los tokens de baja nunca expiran, a propósito: el mecanismo de consentimiento tiene que seguir funcionando para siempre, y revocarlos significa rotar la llave de firma, que vive fuera del repositorio y llega montada en solo lectura en tiempo de ejecución.

Un enlace de confirmación que funciona con la base dormida es una cosa menos que puede pudrirse.

Una rareza de los receptores dictó la config de seguridad: la baja en un clic de Gmail dispara un POST cross-origin directo al endpoint, así que la verificación same-origin de siempre tuvo que desactivarse en esa ruta — ahí autentica el MAC; el header de origen solo estorbaba un estándar.

Qué se rompió durante la construcción

El registro, en orden:

  1. El build de la imagen falló con ERR_PNPM_IGNORED_BUILDS. pnpm install --frozen-lockfile pasaba en local y moría en el contenedor porque el postinstall de esbuild nunca estaba aprobado. El arreglo: meter en la imagen el pequeño pnpm-workspace.yaml que declara los scripts de build permitidos. Media jornada para encontrarlo, un archivo para arreglarlo.
  2. El CSRF bloqueó a Gmail. El POST en un clic de los servidores de Gmail obviamente no trae credenciales same-origin. La ruta ahora confía en el token, y el token es infalsificable.
  3. La entregabilidad tiene techo en el camino gratis. Sin DKIM alineado, mail-tester le da alrededor de 5/10 al montaje, y los filtros estrictos pueden mandar el correo a spam. Ese es el costo honesto de saltarse un relay de pago; el arreglo es conocido y espera a que la lista lo justifique.

Lo que todavía no sabe hacer

Sin tracking de aperturas — ese es un principio: el píxel de tracking es la razón por la que la gente desconfía de las newsletters. Una sola lista, sin segmentos, sin programación más allá de una CLI invocada a mano o por timer, y el techo de entregabilidad de arriba. Enviar es un comando, cli.mjs send --subject ... --file post.md, con un flag --dry que renderiza el texto y el HTML completos para revisar antes de que algo salga. La historia del respaldo es la de SQLite: copiar el archivo, restaurar el archivo, y el modo WAL mantiene la copia consistente mientras el servidor corre. Que arranque bajo supervisión systemd es el resto del trabajo de operaciones.

215 de esas líneas son la CLI. Las otras 1.043 son el consentimiento.

¿Qué parte de tu stack es una plataforma rentada haciendo trabajo que preferirías tener en un archivo que puedes leer? ¿Y qué suscripción sigues pagando porque migrar los datos suena peor que la factura?

Suscríbete a la de [mrsaynothing.dev/newsletter](/en/newsletter) — corre sobre esto.

FAQ

¿Una newsletter autohospedada entrega bien?

Envía por SMTP corriente, así que cae bajo el mismo techo que cualquier remitente pequeño: sin DKIM alineado, mail-tester le da alrededor de 5/10 al montaje y los filtros estrictos pueden mandarla a spam. Sobra para una lista pequeña; un relay arregla el caso de los filtros estrictos.

¿Por qué node:sqlite y no Postgres?

Todo el estado son dos tablas — subscribers y sends. SQLite en modo WAL se encarga sin ningún demonio, y un respaldo es copiar un archivo. Postgres era la dependencia más grande para el trabajo más chico.

¿Cómo funciona la baja en un clic?

Cada correo lleva los encabezados List-Unsubscribe y List-Unsubscribe-Post según la RFC 8058. Gmail y Yahoo los exigen a remitentes con volumen desde febrero de 2024 — el encabezado es toda la función.

— mrsaynothing

— mrsaynothing

Builds, roturas y lo que de verdad se publicó.

Comenta esta entrada en dev.to dev.to ↗

Recibe el próximo build log por email

Un email por post. Con pruebas y errores incluidos.

self-hosted · sin terceros · baja con un clic

¿qué es esto?

Generador de git undo: el comando exacto para tu lío

¿Te gustan estos artículos? Hago esto para vivir. recibir la newsletter