Мой сервер рассылки — это 1 258 строк node:sqlite

Рассылка этого сайта работает на самописном сервере в 1 258 строк: двойное подтверждение, отписка в один клик, CLI для отправки — и честный потолок доставляемости.

HUMANавтор @mrsaynothing30d4 мин чтения

Рассылка этого сайта работает на сервере, который я написал сам: 1 258 строк на TypeScript, Svelte и SQL. Двойное подтверждение, отписка в один клик, CLI для отправки, health-эндпоинт. Ни аккаунта на платформе, ни ежемесячного счёта, а список подписчиков живёт в одном файле, который я копирую через rsync.

Рассылка — это три эндпоинта, две таблицы и один заголовок письма, который платформы от тебя прячут.

Именно заголовок и сделал работу короткой. В феврале 2024-го Google и Yahoo сделали отписку в один клик обязательной для массовых отправителей, реализовав RFC 8058 — заголовки List-Unsubscribe и List-Unsubscribe-Post в каждом письме. Самая сложная часть email-маркетинга стала стандартизированным заголовком. Всё, что платная платформа добавляет вокруг, — либо это, либо адресная книга.

Зачем вообще селфхостить рассылку?

Честная опись: список маленький, объём — одно письмо на пост, остальные сервисы сайта и так у меня. Арендовать платформу ради этого значило бы отдать третьей стороне единственное отношение с аудиторией, которым я действительно владею, плюс формат экспорта с вежливым названием и мутной спецификацией. Свой сервер занял полдня на ядро и ещё один — на края.

Этот сервер Хостинг-платформа
Строк кода, которые я могу прочитать 1 258, все 0
Владение списком один файл SQLite кнопка экспорта
Отписка заголовок RFC 8058, подпись своя настройка в панели
Потолок доставляемости ~5/10 на mail-tester, пока DKIM не выровнен их репутация
Время до первой отправки выходные вечер

Таблица — это вся сделка на одном экране. Платформа выигрывает репутацией доставляемости и тем, что думать не надо; файл — владением и возможностью прочитать всё в редакторе.

Как работает двойное подтверждение без сессий?

Весь поток несут три файла. В db.ts две таблицы — subscribers с ограничением CHECK, которое запирает status на pending, confirmed или unsubscribed, и sends для аудита. Эндпоинт подписки принимает один POST:

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

Прежде чем смотреть на твою почту, эндпоинт смотрит на поле формы с именем website. Люди его никогда не видят, поэтому оно пустое; боты заполняют всё, поэтому заполненное поле покупает им фальшивое ok и больше ничего. После ханипота адрес попадает в ведро токенов — 10 запросов в час на IP, одна повторная отправка на адрес раз в 10 минут, — а ответы нарочно одинаковые, подписан адрес или нет, чтобы через эндпоинт нельзя было никого перечислять.

Ссылка подтверждения несёт подписанный токен вместо сессии:

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}`;
}

Проверка — это timingSafeEqual против пересчитанного MAC. Ни таблицы токенов, ни сроков действия — подпись единственная власть, поэтому ссылка подтверждения работает, даже когда база посреди перезапуска. Токены отписки не истекают вовсе, нарочно: механика согласия обязана работать вечно, а отозвать её значит сменить ключ подписи, который живёт вне репозитория и монтируется только на чтение.

Ссылка подтверждения, работающая при спящей базе, — это одна вещь меньше, способная сгнить.

Один каприз получателей определил настройки безопасности: отписка в один клик у Gmail бьёт кросс-доменным POST прямо в эндпоинт, так что привычную same-origin проверку на этом маршруте пришлось отключить — там аутентифицирует MAC, а заголовок Origin только мешал стандарту.

Что ломалось по пути

Журнал, по порядку:

  1. Сборка образа упала на ERR_PNPM_IGNORED_BUILDS. pnpm install --frozen-lockfile локально проходил, а в контейнере умирал, потому что postinstall-скрипт esbuild никогда не был одобрен. Фикс: положить в образ маленький pnpm-workspace.yaml, объявляющий разрешённые скрипты сборки. Полдня на поиск, один файл на исправление.
  2. CSRF заблокировал Gmail. POST в один клик от серверов Gmail, очевидно, не несёт same-origin-креденшелов. Маршрут теперь доверяет токену, а токен подделать нельзя.
  3. У доставляемости есть потолок на бесплатном пути. Без выровненного DKIM mail-tester даёт конфигурации около 5/10, и строгие фильтры могут отправить письмо в спам. Это честная цена отказа от платного relay; фикс известен и ждёт, когда список его оправдает.

Чего он всё ещё не умеет

Никакого трекинга открытий — это принцип: пиксель трекинга и есть причина, по которой люди не доверяют рассылкам. Один список, без сегментов, без планировки кроме CLI, вызываемой руками или по таймеру, плюс потолок доставляемости выше. Отправка — одна команда, cli.mjs send --subject ... --file post.md, с флагом --dry, который рендерит полный текст и HTML на проверку до того, как что-то уйдёт. История бэкапов — это история SQLite: скопируй файл, верни файл, а режим WAL держит копию консистентной на работающем сервере. Запуск под супервизией systemd — остальная часть операционной работы.

215 из этих строк — CLI. Остальные 1 043 — согласие.

Какая часть твоего стека — арендованная платформа, делающая работу, которую ты бы предпочёл держать в файле, который можешь прочитать? И за какую подписку ты продолжаешь платить, потому что миграция данных звучит хуже счёта?

Подписаться на здешнюю — mrsaynothing.dev/newsletter; она работает ровно на этом.