Как отправлять письма, чтобы не улетать в спам: DKIM-подпись, прогрев IP и DNS-грабли
Транзакционные письма — подтверждения регистрации, сброс пароля, чеки — кажутся простейшей задачей: подключил SMTP, вызвал send(), готово. Ровно до момента, когда письма начинают тихо улетать в спам Mail.ru и Яндекса, а ты не понимаешь почему: код не менялся, ошибок нет, письма «отправлены».
Дело почти никогда не в коде отправки. Дело в трёх DNS-записях, репутации IP-адреса и криптографической подписи, которую надо собрать руками правильно. Я строю свой сервис транзакционной почты и по ходу разобрал всю эту машинерию до винтика — включая граблю, где две совершенно правильные SPF-записи вместе роняют доставляемость всего домена.
Ниже — как устроена доставляемость на самом деле: DKIM-подпись по RFC, прогрев IP-адресов, три DNS-записи, которые решают всё, и разбор реальной ошибки, из-за которой мои письма попадали в спам.
Сразу честно про рамки: сервис в открытом бета-тесте, движок готов, но реальную репутацию и статистику доставляемости он наберёт уже на первых отправках. Поэтому это статья про архитектуру и грабли реализации, а не про достигнутые проценты — цифрами доставляемости, которых пока нет, я тут размахивать не буду.
Почему нельзя просто взять SMTP
Технически отправить письмо действительно просто — установил соединение с почтовым сервером получателя, передал текст. Проблема в том, что современный почтовый сервер получателя письмо примет и молча положит в спам, если отправитель не доказал свою легитимность.
Mail.ru и Яндекс, принимая письмо, проверяют три вещи независимо:
-
SPF — имеет ли право этот IP-адрес отправлять почту от имени домена.
-
DKIM — не подделано ли письмо в пути, подтверждается криптоподписью.
-
DMARC — что делать, если SPF или DKIM не прошли.
Провалилась любая из трёх — и письмо в лучшем случае в спаме, в худшем отклонено совсем. И тут важная деталь российской специфики: почти все гайды по доставляемости написаны под Gmail. Mail.ru и Яндекс похожи в основах, но строже к репутации нового IP и внимательнее к деталям записей. Поэтому пришлось разбираться самому, а не переводить чужое.
DKIM своими руками
DKIM — это криптографическая подпись письма. Отправитель подписывает письмо приватным ключом, получатель проверяет публичным, который лежит в DNS. Совпало — письмо не подделано.
Большинство библиотек делают это за тебя, но я хотел контролировать процесс целиком, потому что именно здесь чаще всего кроются причины «подпись не проходит». Разберу по шагам.
Сначала генерируется пара ключей — RSA-2048:
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
Публичный ключ кладётся в DNS в TXT-запись вида v=DKIM1; k=rsa; p=<base64>. Приватный хранится зашифрованным (у меня — AES-GCM) и никогда не покидает сервер.
Дальше самое тонкое — каноникализация. Прежде чем подписать письмо, его надо привести к каноническому виду, потому что по пути почтовые серверы могут незначительно менять пробелы и переносы строк. Если подписать письмо как есть, любая промежуточная система сломает подпись. RFC 6376 описывает алгоритм relaxed, который сводит заголовки и тело к предсказуемому виду:
function canonicalizeHeaderRelaxed(name, value) {
const lcName = name.toLowerCase().trim();
let v = value.replace(/rn[ t]+/g, ' '); // разворачиваем переносы
v = v.replace(/[ t]+/g, ' '); // схлопываем пробелы
v = v.replace(/^s+|s+$/g, ''); // обрезаем края
return `${lcName}:${v}`;
}
Тело канонизируется похоже, плюс убираются пустые строки в конце и добавляется ровно один финальный перенос. Ошибка на этом шаге — самая частая причина «DKIM не проходит»: подписал не то, что уедет по проводу.
Затем считается хеш тела, собирается заголовок DKIM-Signature с пустым полем подписи, всё это подписывается приватным ключом, и подпись вписывается обратно:
const canonBody = canonicalizeBodyRelaxed(body);
const bodyHash = createHash('sha256').update(canonBody).digest('base64');
// ... собираем DKIM-Signature с bh=<bodyHash> и пустым b=
const signer = createSign('RSA-SHA256');
signer.update(canonHeaders + 'rn' + canonDkim);
const signature = signer.sign(privateKeyPem).toString('base64');
Отдельная тонкость — какие заголовки подписывать и в каком порядке. Я подписываю from, to, subject, date, message-id, mime-version, content-type. Порядок фиксирован и попадает в тег h= подписи. Если получатель пересоберёт заголовки в другом порядке при проверке — подпись развалится, поэтому список и порядок должны совпадать на подписи и на проверке.
RSA-2048 достаточно: Gmail, Яндекс и Mail.ru все принимают rsa-sha256. Ed25519 можно добавить вторым селектором позже, но как единственный алгоритм он рискован — не все проверяющие его поддерживают.
Три записи в DNS, которые решают всё
DKIM-подпись бесполезна, если в DNS нет правильных записей. Их три, и каждая делает свою работу.
SPF перечисляет, каким IP-адресам разрешено слать почту от домена. Запись одна на домен, начинается с v=spf1:
v=spf1 include:_spf.synapsea.agency -all
include подключает список разрешённых серверов, -all в конце означает «всё остальное — запретить жёстко». Именно жёсткий -all, а не мягкий ~all, потому что мягкий говорит получателю «скорее запретить, но можно и пропустить» — лишний повод для спам-фильтра усомниться.
DKIM — та самая TXT-запись с публичным ключом, на поддомене <селектор>._domainkey.<домен>.
DMARC — политика на случай провала проверок, на _dmarc.<домен>:
v=DMARC1; p=quarantine; rua=mailto:dmarc@...
p=quarantine говорит «если SPF и DKIM не сошлись — в спам», rua — адрес для отчётов, куда почтовые системы шлют статистику по твоему домену. Отчёты бесценны: это единственный способ увидеть, что кто-то шлёт письма от твоего имени или что подпись массово не проходит.
Проверять наличие и правильность этих записей вручную — мучение, поэтому в сервисе это автоматизировано. Домен считается верифицированным, только когда все три записи на месте и совпадают с ожидаемыми:
const [dkim, spf, dmarc] = await Promise.all([
checkDkim(domain, dkimSelector, dkimPublicKey),
checkSpf(domain, spfInclude),
checkDmarc(domain),
]);
return { dkimVerified: dkim, spfVerified: spf, dmarcVerified: dmarc };
Грабля, на которой я обжёгся: две правильные SPF-записи
А теперь обещанный случай, который стоил мне разбирательств и попадания писем в спам.
У меня на домене одновременно работали два почтовых сервиса: Timeweb для одной задачи и собственный движок для другой. Под каждый я добросовестно прописал SPF-запись. Получилось так:
v=spf1 include:spf.timeweb.ru -all
v=spf1 include:_spf.synapsea.agency -all
Каждая запись по отдельности — абсолютно корректна. Проблема в том, что SPF-запись на домене должна быть ровно одна. Это прямо прописано в RFC 7208: домен не должен иметь несколько записей, начинающихся с v=spf1.
Что происходит, когда их две. Принимающий сервер запрашивает SPF, находит две записи, начинающиеся с v=spf1, и не знает, какой следовать. По стандарту это PermError — постоянная ошибка. На практике сервер чаще всего игнорирует обе записи целиком. То есть SPF не просто частично работает — он не работает вообще, как будто его нет. И письма летят в спам, потому что для получателя отправитель внезапно стал неаутентифицированным.
Коварство в том, что каждая запись валидна, инструменты проверки одной записи показывают «всё хорошо», а домен при этом сломан. Я не сразу понял, что проблема в самом факте наличия двух записей, а не в их содержимом.
Починка — объединить в одну запись, слив механизмы include:
v=spf1 include:spf.timeweb.ru include:_spf.synapsea.agency -all
Одна запись, один v=spf1 в начале, все include через пробел, один -all в конце. Три правила, которые я вынес из этой истории:
Первое — v=spf1 в записи ровно один раз, в начале. Самая частая ошибка при объединении — склеить две записи и оставить два префикса v=spf1, это тихо ломает всё.
Второе — механизм all только один, в самом конце.
Третье — разделитель между механизмами это пробел, а не точка с запятой. Точка с запятой разделяет теги в DKIM и DMARC, и на автомате её легко перенести в SPF, где она не работает.
Отдельно про лимит: SPF разрешает не более десяти DNS-запросов на запись, каждый include считается за один. Если почтовых сервисов много, можно упереться в лимит — тогда записи «уплощают», заменяя include на конкретные IP.
Прогрев IP: почему нельзя сразу слать тысячи
Допустим, DKIM подписан, записи в DNS верные. Можно слать? Ещё нет, если IP-адрес новый.
Почтовые системы не доверяют новым IP-адресам. Адрес без истории, который внезапно начинает слать тысячи писем, выглядит ровно как спамер, и попадает в спам целиком, независимо от того, насколько правильно всё настроено. Репутацию надо набирать постепенно — это называется прогревом.
Схема, по которой это работает у меня, — лестница дневных лимитов:
|
Стадия |
Лимит в день |
Условие перехода |
|---|---|---|
|
new |
50 |
старт |
|
warming |
500 |
3 дня здоровых метрик |
|
warm |
5 000 |
14 дней здоровых метрик |
|
trusted |
50 000 |
30 дней здоровых метрик |
«Здоровые метрики» — это два порога, которые проверяются ежедневно по каждому IP:
-
доля отказов (bounce) меньше 2%;
-
доля жалоб на спам (complaint) меньше 0.1%.
Если метрики в норме и на стадии проведено достаточно дней — IP повышается на следующую ступень, лимит растёт. Если метрики поехали — стадия остаётся или откатывается. А если жалоб становится совсем много (у меня порог — больше 1% жалоб при заметном объёме), IP автоматически выводится из ротации на ручной разбор:
if (stats.total > 100 && stats.complaintRate > 0.01) {
updates.isActive = false; // деактивируем, разбор вручную
} else if (sched && healthy && daysAtStage >= sched.nextDays) {
updates.warmupStage = sched.nextStage;
updates.dailyLimit = sched.nextLimit;
}
Когда IP-адресов несколько, письма распределяются между ними не поровну, а по репутации: чем ниже bounce и жалобы у адреса, тем больше писем ему достаётся. Вес считается по формуле, где жалобы штрафуют вдвое сильнее отказов:
const w = Math.max(0.1, Math.min(1, 1 - bounceRate - 2 * complaintRate));
Плюс защита от гонки: когда несколько писем одновременно выбирают IP, счётчик дневной отправки увеличивается атомарно, и адрес не может превысить лимит из-за одновременных отправок.
Оговорюсь честно: это схема, по которой сервис будет прогреваться на реальном трафике. Пороги и лестница взяты из практик отрасли и заложены в код, но проверить их на своей статистике я смогу только когда пойдут первые тысячи писем от бета-тестеров. Поэтому показываю механику, а не результат.
Suppression-лист: не слать на мёртвые адреса
Последний кусок, без которого репутация посыпется, — обработка отказов.
Если письмо вернулось с жёстким отказом (адрес не существует), слать на него снова нельзя — каждый такой bounce бьёт по репутации IP. Поэтому адреса, давшие жёсткий отказ или жалобу, попадают в suppression-лист, и повторные отправки на них блокируются автоматически, ещё до попытки отправки.
Это же защищает от ситуации, когда клиент заливает старую базу с мёртвыми адресами: без suppression-листа первая же массовая отправка по такой базе сожгла бы репутацию IP полностью. С ним — отказавшие адреса отсекаются, а живые продолжают получать письма.
Механика простая: перед отправкой адрес проверяется по списку, отказы и жалобы пополняют список из обработчика входящих отчётов и DSN-сообщений (тех самых «письмо не доставлено», которые возвращает почтовый сервер получателя).
Отслеживание открытий — и почему это тоньше, чем кажется
Раз уж речь о транзакционной почте, стоит сказать про метрики открытий и кликов, потому что тут есть неочевидные тонкости.
Открытие письма отслеживается классически — прозрачным пикселем 1×1, который подгружается с сервера при показе письма. Клик — через ссылку-редирект, которая сначала ведёт на трекинг-эндпоинт, а тот уже перенаправляет на настоящий адрес.
Первая тонкость — ссылки надо подписывать. Если трекинг-ссылка это просто /t/c/<id>/<адрес>, кто угодно может подставить в неё произвольный адрес и гонять чужие письма через твой редирект — открытая уязвимость для фишинга. Поэтому целевой адрес в ссылке подписывается HMAC, и при переходе подпись проверяется способом, устойчивым к атаке по времени:
import { createHmac, timingSafeEqual } from 'crypto';
// подпись при сборке письма, проверка при клике — timingSafeEqual, не ===
Вторая тонкость — счётчик открытий переоценивает реальность. Часть почтовых клиентов (и особенно прокси вроде Apple Mail Privacy Protection) подгружают пиксель заранее, без реального открытия человеком. Поэтому первое открытие фиксируется отдельно от общего счётчика, и к абсолютным числам открытий надо относиться как к верхней оценке, а не к факту. Для транзакционной почты это не критично — там важнее сам факт доставки, — но знать об искажении полезно.
DMARC-отчёты: единственное зеркало
Возвращаясь к DMARC — про его отчёты стоит сказать отдельно, потому что это недооценённый инструмент.
Когда в записи указан rua=mailto:..., почтовые системы получателей начинают присылать на этот адрес агрегированные отчёты: сколько писем пришло от твоего домена, сколько прошло SPF и DKIM, с каких IP-адресов. Это XML-файлы, приходящие обычно раз в сутки от каждого крупного провайдера.
Ценность в том, что это единственный способ увидеть свой домен глазами получателя. Из отчётов видно: проходит ли аутентификация массово или частично, не шлёт ли кто-то письма от твоего имени с чужих адресов (характерный признак спуфинга), не сломалась ли подпись после какого-то изменения. Сервис эти отчёты принимает и разбирает автоматически, сводя в понятную картину вместо сырого XML.
Без DMARC-отчётов ты фактически слеп: письма уходят, а что с ними происходит на стороне Mail.ru или Яндекса — неизвестно до первой жалобы пользователя.
Что в итоге получилось
Собрал движок транзакционной почты, который закрывает всю цепочку доставляемости: DKIM-подпись с правильной каноникализацией, автопроверка SPF/DKIM/DMARC в DNS, прогрев IP по лестнице лимитов с порогами репутации, взвешенное распределение по адресам, suppression-лист. HTTP API и SMTP-релей, совместимый по формату с привычными сервисами.
Ещё раз честно про статус: это открытый бета-тест. Движок построен и работает, но репутацию IP-адресов и реальную статистику доставляемости он наберёт на первых пользователях — прогрев по определению требует недель живого трафика. Так что я показал архитектуру и грабли, а не отчёт о достигнутой доставляемости, которого пока нет и быть не может.
Если строите свою отправку или уже ловили письма в спаме Mail.ru и Яндекса — welcome в комментарии, особенно интересны ваши истории с DNS-записями и прогревом. А если хотите потестировать движок на своих письмах — mail.synapsea.agency, сейчас как раз открытая бета, доступ бесплатный. За баг-репорты по доставляемости благодарю продлением подписки.
Процесс разработки пишу в личном канале — https://t.me/synapseaa.
Автор: ShyDamn

