Транзакционные письма — подтверждения регистрации, сброс пароля, чеки — кажутся простейшей задачей: подключил SMTP, вызвал send(), готово. Ровно до момента, когда письма начинают тихо улетать в спам Mail.ru и Яндекса, а ты не понимаешь почему: код не менялся, ошибок нет, письма «отправлены».

Дело почти никогда не в коде отправки. Дело в трёх DNS-записях, репутации IP-адреса и криптографической подписи, которую надо собрать руками правильно. Я строю свой сервис транзакционной почты и по ходу разобрал всю эту машинерию до винтика — включая граблю, где две совершенно правильные SPF-записи вместе роняют доставляемость всего домена.

Ниже — как устроена доставляемость на самом деле: DKIM-подпись по RFC, прогрев IP-адресов, три DNS-записи, которые решают всё, и разбор реальной ошибки, из-за которой мои письма попадали в спам.

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

Почему нельзя просто взять SMTP

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

Mail.ru и Яндекс, принимая письмо, проверяют три вещи независимо:

  • SPF — имеет ли право этот IP-адрес отправлять почту от имени домена.

  • DKIM — не подделано ли письмо в пути, подтверждается криптоподписью.

  • DMARC — что делать, если SPF или DKIM не прошли.

Что проверяет Mail.ru / Яндекс, принимая письмо
Что проверяет Mail.ru / Яндекс, принимая письмо

Провалилась любая из трёх — и письмо в лучшем случае в спаме, в худшем отклонено совсем. И тут важная деталь российской специфики: почти все гайды по доставляемости написаны под 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(/\r\n[ \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 + '\r\n' + 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 не просто частично работает — он не работает вообще, как будто его нет. И письма летят в спам, потому что для получателя отправитель внезапно стал неаутентифицированным.

Грабля: две «правильные» SPF-записи ломают домен целиком
Грабля: две «правильные» 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: лестница дневных лимитов
Прогрев IP: лестница дневных лимитов

«Здоровые метрики» — это два порога, которые проверяются ежедневно по каждому 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.