Рассылка этого сайта работает на сервере, который я написал сам: 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 только мешал стандарту.
Что ломалось по пути
Журнал, по порядку:
- Сборка образа упала на
ERR_PNPM_IGNORED_BUILDS.pnpm install --frozen-lockfileлокально проходил, а в контейнере умирал, потому что postinstall-скрипт esbuild никогда не был одобрен. Фикс: положить в образ маленькийpnpm-workspace.yaml, объявляющий разрешённые скрипты сборки. Полдня на поиск, один файл на исправление. - CSRF заблокировал Gmail. POST в один клик от серверов Gmail, очевидно, не несёт same-origin-креденшелов. Маршрут теперь доверяет токену, а токен подделать нельзя.
- У доставляемости есть потолок на бесплатном пути. Без выровненного 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 ↗
Следующий лог сборки — на почту
Одно письмо на пост. С доказательствами и ошибками.
что это?Генератор git undo: точная команда для вашего бардака
Нравятся заметки? Я зарабатываю этим на жизнь. получать рассылку