Назад в блог

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

26 сентября 2026 г.

Рассылка этого сайта работает на сервере, который я написал сам: 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](/en/newsletter); она работает ровно на этом.

FAQ

А доставляемость у самохостинг-рассылки нормальная?

Она шлёт через обычный SMTP, так что упирается в тот же потолок, что и любой мелкий отправитель: без выровненного DKIM mail-tester даёт конфигурации около 5/10, а строгие фильтры могут отправить письмо в спам. Для небольшого списка хватает с запасом; relay решает вопрос со строгими получателями.

Почему node:sqlite, а не Postgres?

Всё состояние — это две таблицы: subscribers и sends. SQLite в режиме WAL тянет это без единого демона, а бэкап — копирование одного файла. Postgres была самой крупной зависимостью для самой мелкой задачи.

Как работает отписка в один клик?

Каждое письмо несёт заголовки List-Unsubscribe и List-Unsubscribe-Post по RFC 8058. Gmail и Yahoo требуют отписку в один клик от массовых отправителей с февраля 2024 года — заголовок и есть вся функциональность.

— mrsaynothing

— mrsaynothing

Сборки, поломки и то, что реально доехало.

Обсудить этот пост на dev.to dev.to ↗

Следующий лог сборки — на почту

Одно письмо на пост. С доказательствами и ошибками.

self-hosted · никаких третьих сторон · отписка в один клик

что это?

Генератор git undo: точная команда для вашего бардака

Нравятся заметки? Я зарабатываю этим на жизнь. получать рассылку