Письмо с кодом подтверждения ушло в 14:02. В логе отправки — accepted, код 250, идентификатор сообщения, всё как положено. Человек на той стороне сказал, что ничего не пришло. Проверили через двадцать минут: висит в «Спаме» у Mail.ru, непрочитанное, вместе с двумя предыдущими.

Самое неприятное в этой истории то, что я ничего не менял. Код отправки не трогал неделю, сервер тот же, домен тот же, письма те же самые по шаблону. Просто в какой-то момент часть писем перестала доходить до «Входящих», и никакого сигнала об этом не было: почтовый сервер получателя принял письмо, ответил положительным кодом и молча положил его в спам. С точки зрения моего кода всё прошло идеально.

Я делаю свой сервис транзакционной почты, поэтому копать пришлось до самого низа: как считается DKIM-подпись, что именно проверяет Mail.ru, почему новому IP нельзя сразу давать нагрузку. По дороге я наступил на граблю, которая меня и подкосила — две абсолютно корректные SPF-записи на домене, каждая из которых по отдельности проходит любой валидатор, а вместе они выключают SPF целиком. Ниже — вся эта механика по порядку, с командами, реальным выводом и разбором того, что я сделал не так.

Одну вещь скажу сразу, чтобы потом не было вопросов. Движок собран и работает, но репутацию IP-адресов он набирает прямо сейчас, на первых пользователях. Поэтому в статье будет архитектура, RFC и грабли, а процентов доставляемости не будет — их пока просто неоткуда взять, а рисовать красивые цифры я не хочу.

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

Технически отправить письмо действительно просто. Резолвим MX домена получателя, открываем соединение на 25-й порт, здороваемся, отдаём конверт и тело, получаем 250 OK. Библиотека вроде Nodemailer делает это в пять строк, и первые несколько писем даже дойдут — особенно если вы шлёте сами себе.

Проблема в том, что 250 OK не означает «письмо в почтовом ящике». Он означает «я принял на себя ответственность за это сообщение». Что произойдёт дальше, отправителю не сообщают: письмо может попасть во «Входящие», в «Спам», в «Промоакции» или в невидимую пользователю карантинную папку, и во всех четырёх случаях протокол ответит одинаково. Это принципиальное отличие от HTTP, к которому все привыкли: там 200 значит 200.

Решение о папке принимается по репутации отправителя, а репутация складывается из нескольких независимых проверок. Mail.ru и Яндекс, принимая письмо, смотрят как минимум на SPF (имеет ли этот IP право отправлять почту от имени домена), на DKIM (не подделано ли письмо в пути — подтверждается криптоподписью) и на DMARC (что делать, если первые две не сошлись). Плюс к этому — история конкретного IP-адреса, история домена, доля жалоб от пользователей, соотношение отправленного к прочитанному и ещё десяток сигналов, о которых нам никто не рассказывает.

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

Результат этих проверок, кстати, видно прямо в заголовках доставленного письма. Открываете любое своё письмо в Mail.ru, «Ещё» → «Служебные заголовки», и ищете Authentication-Results. Там будет что-то вроде:

Authentication-Results: mx.mail.ru; spf=pass (mx.mail.ru: domain of
  synapsea.agency designates 1.2.3.4 as permitted sender)
  smtp.mailfrom=noreply@synapsea.agency; dkim=pass header.d=synapsea.agency;
  dmarc=pass header.from=synapsea.agency

Три pass — минимальная гигиена. Если хоть где-то fail, none или permerror, дальше можно не гадать, а идти чинить конкретную запись. У Яндекса заголовок называется так же и лежит там же (в веб-интерфейсе — «Свойства письма»).

Отдельно про российскую специфику, потому что она реально мешает искать решение. Девяносто процентов гайдов по доставляемости написаны под Gmail: там свои инструменты, своя логика, свои требования от февраля 2024 года про обязательный DMARC для массовых отправителей. Mail.ru и Яндекс в основах устроены так же, но заметно строже к новому IP-адресу и внимательнее к мелочам в записях. Плюс у них есть собственные постмастеры — postmaster.mail.ru и postoffice.yandex.ru — куда стоит завести домен до первой отправки, а не после. Там видно долю спама, долю прочтений и жалобы, то есть примерно то, что видит про вас почтовик. Бесплатно, регистрация через подтверждение владения доменом.

DKIM своими руками

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

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

Начинается всё скучно. Генерируем пару ключей:

import { generateKeyPairSync } from 'node:crypto';

const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

RSA-2048 здесь не от консерватизма, а потому что его принимают все: Gmail, Яндекс, Mail.ru, корпоративные Exchange. Ed25519 (RFC 8463) короче и приятнее, но как единственный алгоритм он рискован — часть проверяющих его до сих пор не знает и вернёт dkim=neutral. Разумный вариант — второй селектор с Ed25519 в дополнение к RSA, но это можно и потом.

Публичный ключ уезжает в DNS в TXT-запись на <селектор>._domainkey.<домен> в формате v=DKIM1; k=rsa; p=<base64 без переносов>. Приватный у меня шифруется AES-256-GCM мастер-ключом из окружения и с сервера не уезжает никуда; в базе лежит шифротекст, расшифровка происходит в момент подписи и живёт в памяти ровно столько, сколько нужно.

А вот дальше начинается то самое место, где всё ломается: каноникализация. Смысл её в том, что почтовые серверы по пути имеют право слегка менять письмо — схлопнуть пробелы, переставить перенос строки, дописать свой заголовок. Если подписать байты как есть, первый же промежуточный сервер сломает подпись. Поэтому RFC 6376 описывает процедуру приведения заголовков и тела к предсказуемому виду, и подписывается уже результат. Алгоритмов два, simple (почти ничего не меняет, ломается от любого чиха) и relaxed (терпимый), и на практике все используют relaxed/relaxed.

Для заголовков relaxed делает четыре вещи: имя в нижний регистр, развернуть многострочные значения в одну строку, схлопнуть последовательности пробелов и табов в один пробел, обрезать пробелы по краям значения:

function canonicalizeHeaderRelaxed(name, value) {
  const lcName = name.toLowerCase().trim();
  let v = value.replace(/\r\n[ \t]+/g, ' ');   // разворачиваем folding
  v = v.replace(/[ \t]+/g, ' ');                // схлопываем пробелы
  v = v.replace(/^\s+|\s+$/g, '');              // края
  return `${lcName}:${v}`;
}

С телом та же логика плюс два правила, которые я в первый раз прочитал невнимательно и потом два часа искал ошибку: убрать пустые строки в конце и, если тело непустое, оставить ровно один финальный CRLF. Именно CRLF, а не \n.

function canonicalizeBodyRelaxed(body) {
  let b = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');  // нормализуем к \n
  b = b.split('\n')
       .map((line) => line.replace(/[ \t]+/g, ' ').replace(/[ \t]+$/, ''))
       .join('\r\n');                                        // и обратно в CRLF
  b = b.replace(/(\r\n)+$/, '');                              // режем хвостовые пустые
  return b.length ? b + '\r\n' : '';                          // ровно один финальный
}

Обратите внимание на последнюю строку: у пустого тела канонический вид — пустая строка, а не CRLF. Это отличие relaxed от simple, и на нём я и залип. Node.js на Linux с удовольствием отдаст вам \n, вы посчитаете хеш от одного, а по проводу уедет другое, и получатель получит bh mismatch — то есть подпись формально валидна, а хеш тела не сошёлся.

Диагностируется это, кстати, довольно приятно. У пустого тела в relaxed канонический вид — пустая строка, а sha256 от пустой строки в base64 всегда один и тот же:

47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=

Если вы видите эту строку в теге bh= собственной подписи — поздравляю, вы подписали пустое тело, и искать надо не в криптографии, а в том, что в функцию подписи приехало. У simple аналогичная константа для пустого тела — frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY= (там пустое тело канонизируется в один CRLF). Я эти два значения в итоге просто держу в тестах.

Сама сборка подписи выглядит так. Считаем хеш канонизированного тела, собираем заголовок DKIM-Signature со всеми тегами и пустым b=, канонизируем его самого (последний, без завершающего CRLF), подписываем конкатенацию канонизированных заголовков и этого пустого DKIM-Signature, и вписываем результат обратно в b=:

const canonBody = canonicalizeBodyRelaxed(body);
const bodyHash = createHash('sha256').update(canonBody).digest('base64');

const headerList = ['from','to','subject','date','message-id','mime-version','content-type'];
const canonHeaders = headerList
  .map((h) => canonicalizeHeaderRelaxed(h, headers[h]))
  .join('\r\n');

const dkimTags =
  `v=1; a=rsa-sha256; c=relaxed/relaxed; d=${domain}; s=${selector}; ` +
  `t=${Math.floor(Date.now() / 1000)}; h=${headerList.join(':')}; bh=${bodyHash}; b=`;

const signer = createSign('RSA-SHA256');
signer.update(canonHeaders + '\r\n' + canonicalizeHeaderRelaxed('dkim-signature', dkimTags));
const signature = signer.sign(privateKeyPem).toString('base64');

Тонкость, из-за которой люди ломают голову дольше всего: DKIM-Signature подписывает сам себя, но с вырезанным значением b=. То есть в момент подписи там пусто, а в момент проверки получатель должен вырезать значение обратно и получить ту же строку. Если ваш сборщик заголовков потом что-то дописал в DKIM-Signature (например, переносы для красоты) — проверка развалится.

Список заголовков в h= тоже не случайный. Я подписываю from, to, subject, date, message-id, mime-version, content-type — именно в этом порядке, потому что порядок попадает в тег и получатель будет собирать строку для проверки по нему же. Класть туда received или что-то, что почтовые серверы дописывают сами, нельзя категорически. А вот from подписывать обязательно, иначе вся затея теряет смысл: DMARC требует, чтобы домен из d= совпадал с доменом в From, и это называется alignment.

И последнее по DKIM: не используйте тег l=. Он позволяет подписать только первые N байт тела, чтобы подпись переживала добавление подписей рассылочных списков, и звучит удобно. На практике он открывает возможность дописать в конец письма произвольный текст, не сломав подпись, поэтому часть почтовиков относится к письмам с l= с подозрением.

Проверять всё это удобнее всего не глазами, а swaks — он умеет отправить письмо с готовой DKIM-подписью и показать весь SMTP-диалог:

swaks --to check@mail.ru --from noreply@synapsea.agency \
      --server localhost --port 587 --tls \
      --header "Subject: dkim test" --body "test"

Дальше открываете письмо в ящике и смотрите Authentication-Results. Если там dkim=fail (body hash did not verify) — проблема в каноникализации тела, если dkim=fail (signature verification failed) — в заголовках или в ключе, если dkim=permerror — почтовик не смог прочитать вашу TXT-запись, и это отдельная история, о которой ниже.

Три записи в DNS, которые решают всё

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

SPF отвечает на вопрос «кому вообще разрешено слать почту от этого домена». Это одна TXT-запись на корне домена, которая начинается с v=spf1:

v=spf1 include:_spf.synapsea.agency -all

Механизм include подтягивает список разрешённых серверов провайдера (в моём случае — свой), -all в конце означает жёсткий запрет для всего остального. Про -all против ~all спорят до сих пор: мягкий ~all говорит получателю «скорее всего это не мы, но ты решай сам», и на переходный период его действительно берут, чтобы не отстрелить себе легитимную почту с забытого сервера. Но постоянно жить на ~all смысла нет — вы фактически сообщаете, что не знаете, кто шлёт от вашего имени. Я держу -all и слежу за DMARC-отчётами.

DKIM — это та самая TXT-запись с публичным ключом на <селектор>._domainkey.<домен>. И вот здесь притаилась грабля, которая ест часа два жизни у каждого, кто делает DKIM впервые. Строка TXT в DNS по стандарту не может быть длиннее 255 байт, а base64 от публичного ключа RSA-2048 — это около 392 символов. Значит, запись обязана состоять из нескольких строк, которые резолвер склеивает при чтении. Хорошие панели делают это сами, плохие обрезают на 255 символах и не говорят об этом, а совсем весёлые вставляют пробел на месте склейки, и ключ становится невалидным при внешне правильном виде. Поэтому смотреть надо не в панель, а в то, что реально отдаёт DNS:

$ dig +short TXT s1._domainkey.synapsea.agency
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvN2" "8kQb...остаток...QIDAQAB"

Две строки в кавычках — это нормально, так и должно быть. А вот если после склейки ключ короче исходного или в нём завёлся пробел — вот вам и dkim=permerror при идеально посчитанной подписи. Быстрая проверка на стороне: opendkim-testkey -d synapsea.agency -s s1 -vvv, он сам сходит в DNS, склеит и сверит с приватным ключом.

DMARC живёт на _dmarc.<домен> и говорит получателю, что делать, если SPF и DKIM не сошлись:

v=DMARC1; p=quarantine; rua=mailto:dmarc@synapsea.agency; fo=1; adkim=r; aspf=r

Начинать надо не с quarantine, а с p=none. Это режим наблюдения: почтовики ничего не меняют в своём поведении, но начинают присылать отчёты. Пару недель на none, смотрим отчёты, убеждаемся, что всё легитимное проходит проверку, и только потом ужесточаем до quarantine, а при желании — до reject. Если поставить reject сразу, первым делом отвалится какая-нибудь забытая форма обратной связи на сайте, которая шлёт письма через хостинг напрямую, и вы об этом узнаете от клиента.

Ещё пара мелочей из практики. Во многих панелях хостинга в поле «имя записи» надо писать _dmarc, а не _dmarc.example.com — панель сама допишет домен, и если написать полностью, получится _dmarc.example.com.example.com. Выглядит глупо, но я видел это столько раз, что перестал считать. И проверять после изменений надо не через сайт-валидатор, а через dig, потому что валидаторы кешируют, а dig @8.8.8.8 покажет то, что реально отдаётся.

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

const [dkim, spf, dmarc] = await Promise.all([
  checkDkim(domain, dkimSelector, dkimPublicKey),
  checkSpf(domain, spfInclude),
  checkDmarc(domain),
]);

return {
  dkimVerified: dkim,
  spfVerified: spf,
  dmarcVerified: dmarc,
  verified: dkim && spf && dmarc,
};

Грабля, на которой я обжёгся: две правильные SPF-записи

Теперь та самая история, ради которой я, честно говоря, эту статью и затевал.

Домен synapsea.agency у меня обслуживал почту двумя разными способами. Обычная корпоративная почта (та, куда пишут люди) жила на Timeweb, а транзакционные письма уходили через собственный движок. Когда я подключал корпоративную почту, панель Timeweb предложила «настроить DNS автоматически» — я нажал кнопку, она добавила нужные записи, всё заработало, я забыл. Через какое-то время я поднял свой SMTP и добросовестно дописал SPF для него. Тоже руками, тоже правильно.

Получилось так:

$ dig +short TXT synapsea.agency
"v=spf1 include:spf.timeweb.ru -all"
"v=spf1 include:_spf.synapsea.agency -all"
Две «правильные» SPF-записи ломают домен целиком
Две «правильные» SPF-записи ломают домен целиком

Каждая из этих строк абсолютно корректна. Любой валидатор, которому вы скормите любую из них по отдельности, скажет, что всё прекрасно. Проблема в том, что SPF-запись на домене должна быть ровно одна, и это не рекомендация, а прямое требование RFC 7208: домен не должен иметь несколько записей, начинающихся с v=spf1.

Что происходит в этот момент на стороне получателя. Он запрашивает TXT корня домена, фильтрует записи по префиксу v=spf1, находит две и не имеет никакого способа выбрать между ними. По стандарту он обязан вернуть PermError — постоянную ошибку. А PermError в подавляющем большинстве реализаций трактуется так же, как отсутствие SPF вообще. То есть SPF не деградирует и не работает наполовину, он выключается целиком: обе записи идут в мусор, и отправитель для получателя становится неаутентифицированным. Со всеми вытекающими.

Коварство в том, что признаков нет. Панель показывает две зелёные галочки. Сервис проверки SPF, куда вы вставляете одну строку, говорит «валидно». Письма отправляются, код 250 приходит, в логах чисто. Единственное, что изменилось — доля попаданий в спам, и заметить это можно только по жалобам пользователей или по постмастеру.

Я, к своему стыду, сначала полез перепроверять DKIM, потому что был уверен, что проблема где-то в моей свежей реализации каноникализации — она была новая, я ей не доверял. Потратил вечер, убедился, что подпись проходит, и только потом додумался посмотреть на домен целиком, а не на свою часть. Вывод dig выше — это буквально то, что я увидел, и мне понадобилось секунд десять, чтобы понять.

Чинится объединением в одну запись: все механизмы include перечисляются через пробел, v=spf1 в начале один раз, all в конце один раз.

v=spf1 include:spf.timeweb.ru include:_spf.synapsea.agency -all

Из этой истории я вынес пару правил, которые теперь проверяю механически. Первое и самое обидное: при объединении очень легко склеить две записи как есть и оставить два v=spf1 внутри одной строки — визуально всё на месте, а SPF снова не работает. Второе: разделитель между механизмами — пробел, а не точка с запятой. Точка с запятой разделяет теги в DKIM и DMARC, и после часа работы с DMARC-записью рука ставит её на автомате.

И третье, про что стоит помнить заранее: SPF разрешает не больше десяти DNS-запросов на проверку одной записи. Каждый include — минимум один запрос, а если внутри include есть свои include (а у крупных провайдеров они есть), то и больше. Превысили — снова PermError со всеми последствиями. Проверить сколько у вас сейчас можно на dmarcian.com/spf-survey или тем же dig, если руками. Когда упираетесь в лимит, записи «уплощают»: заменяют include на конкретные ip4:/ip6:, которые запросов не стоят. Минус в том, что уплощённую запись надо обновлять руками, когда провайдер меняет свои адреса.

Прогрев IP: почему нельзя сразу слать тысячи

Допустим, подпись считается правильно, все три записи на месте и сошлись. Можно наконец слать? Если IP-адрес новый — ещё нет.

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

У меня это устроено как лестница дневных лимитов, по которой адрес поднимается при хороших показателях:

Прогрев IP: лестница дневных лимитов
Прогрев IP: лестница дневных лимитов

Стадия

Лимит в день

Условие перехода

new

50

старт

warming

500

3 дня здоровых метрик

warm

5 000

14 дней здоровых метрик

trusted

50 000

30 дней здоровых метрик

Под «здоровыми метриками» я понимаю два порога, которые считаются по каждому адресу раз в сутки: доля жёстких отказов ниже 2% и доля жалоб на спам ниже 0.1%. Цифры не мои, это отраслевой консенсус — примерно те же значения вы найдёте в требованиях Gmail к массовым отправителям и в рекомендациях постмастера Mail.ru. Ноль целых одна десятая процента жалоб — это одна жалоба на тысячу писем, и звучит она как очень мягкий порог ровно до момента, когда вы посчитаете, сколько людей жмут «Спам» вместо «Отписаться».

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

if (stats.total > 100 && stats.complaintRate > 0.01) {
  updates.isActive = false;          // 1% жалоб — деактивация, разбор вручную
} else if (sched && healthy && daysAtStage >= sched.nextDays) {
  updates.warmupStage = sched.nextStage;
  updates.dailyLimit  = sched.nextLimit;
}

Условие stats.total > 100 там не для красоты. Без него первые же два письма, из которых одно ушло на несуществующий адрес, дадут 50% отказов и убьют свежий IP на старте. Проценты на маленьких числах вообще врут, и это надо закладывать в код, а не вспоминать по факту.

Когда адресов несколько, письма между ними распределяются не поровну, а по репутации: чем меньше у адреса отказов и жалоб, тем больше писем ему достаётся. Вес считается тривиально, с двойным штрафом за жалобу относительно отказа (жалоба хуже, потому что это осознанное действие живого человека):

const w = Math.max(0.1, Math.min(1, 1 - bounceRate - 2 * complaintRate));

Дальше по этим весам адрес и выбирается, обычной рулеткой:

function pickIp(pool) {
  const alive = pool.filter((ip) => ip.isActive && ip.sentToday < ip.dailyLimit);
  if (!alive.length) throw new Error('no capacity in pool');

  const weighted = alive.map((ip) => ({ ip, w: reputationWeight(ip) }));
  const total = weighted.reduce((s, x) => s + x.w, 0);

  let r = Math.random() * total;
  for (const { ip, w } of weighted) {
    if ((r -= w) <= 0) return ip;
  }
  return weighted[weighted.length - 1].ip;   // страховка от накопленной погрешности
}

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

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

Suppression-лист: не слать на мёртвые адреса

Кусок, без которого всё предыдущее не имеет смысла, — обработка отказов.

Логика простая: если письмо вернулось с жёстким отказом (адрес не существует, домен не существует), слать туда повторно нельзя никогда. Каждая такая попытка — минус к репутации IP, а поскольку такие адреса в базах живут годами, одна и та же ошибка повторяется бесконечно. Поэтому адрес после жёсткого отказа или жалобы попадает в suppression-лист, и следующая отправка на него блокируется до попытки соединения, а не после.

Выглядит жёсткий отказ примерно так, если смотреть сам диалог:

<<< 250 2.1.0 <noreply@synapsea.agency> ok
>>> RCPT TO:<staryi-adres@mail.ru>
<<< 550 5.1.1 <staryi-adres@mail.ru>: Recipient address rejected:
    User unknown in virtual mailbox table

Отличать жёсткий отказ от мягкого приходится по коду ответа SMTP: 5xx — постоянная ошибка, 4xx — временная (ящик переполнен, сервер занят, greylisting). На мягких надо ретраить с нарастающей паузой, на жёстких — сразу в список. Звучит однозначно, но в реальности есть пограничные случаи: некоторые почтовики отдают 4xx там, где на самом деле имеют в виду «больше не приходи», и если тупо ретраить, вы будете долбиться в закрытую дверь и портить себе репутацию. Я в итоге завёл счётчик повторов и после нескольких подряд одинаковых 4xx отправляю адрес в тот же список.

Проверка перед отправкой стоит один запрос и экономит репутацию:

const blocked = await db.suppression.findFirst({
  where: { accountId, email: to.toLowerCase() },
  select: { reason: true, createdAt: true },
});

if (blocked) {
  await logEvent(messageId, 'suppressed', blocked.reason);
  return { id: messageId, status: 'suppressed', reason: blocked.reason };
}

Отдаю при этом не ошибку, а нормальный ответ со статусом suppressed. Клиентский код не должен падать из-за того, что адрес в базе мёртвый, — он должен об этом узнать и пойти дальше.

Отдельная ценность suppression-листа выясняется, когда приходит первый клиент со старой базой. Человек накопил адреса за пять лет, заливает их и жмёт «отправить». Без списка первая же такая отправка выжжет репутацию IP полностью за один заход, причём выжжет она её не только этому клиенту, а всем, кто сидит на том же адресе. Со списком мёртвые адреса отсеиваются по мере обнаружения, а живые продолжают получать письма.

Наполняется список из двух источников: из кодов ответа при самой отправке и из разбора DSN — тех самых автоматических «письмо не доставлено», которые возвращает почтовый сервер получателя. DSN, к слову, парсится хуже, чем хотелось бы: формат описан в RFC 3464, но следуют ему по-разному, и часть серверов присылает человекочитаемый текст вместо структурированной части. Приходится держать и парсер по стандарту, и набор регулярок для тех, кто стандарт читал по диагонали.

Отслеживание открытий — и почему это тоньше, чем кажется

Раз уж речь про транзакционную почту, надо сказать про метрики, потому что здесь есть две вещи, которые легко сделать неправильно.

Механика классическая: открытие фиксируется прозрачным пикселем 1×1, который подгружается с сервера при показе письма, клик — через ссылку-редирект, которая сначала ведёт на трекинг-эндпоинт, а тот перенаправляет на настоящий адрес.

Первая проблема — безопасность редиректа. Если трекинг-ссылка выглядит как /t/c/<id>?url=<адрес>, вы только что построили открытый редиректор на своём домене. Кто угодно подставит туда что угодно, и ваш домен, который вы старательно грели, будет отправлять людей на фишинговые страницы. Поэтому целевой адрес подписывается HMAC, и подпись проверяется сравнением, устойчивым к атаке по времени:

import { createHmac, timingSafeEqual } from 'node:crypto';

const expected = createHmac('sha256', secret).update(payload).digest();
const provided = Buffer.from(sig, 'base64url');
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
  return reply.code(403).send();
}

Проверка длины перед timingSafeEqual обязательна — на буферах разной длины он бросает исключение, а не возвращает false. Обычное === тут не годится принципиально: сравнение строк выходит на первом несовпавшем байте, и по времени ответа подпись подбирается побайтово.

Вторая проблема — счётчик открытий врёт в большую сторону, и врёт систематически. Apple Mail Privacy Protection с 2021 года подгружает все изображения в письме заранее, через свой прокси, независимо от того, открыл его человек или нет. Часть почтовых клиентов делает то же самое для предзагрузки. В результате «открытие» фиксируется у писем, которые никто не читал, и доля таких срабатываний в некоторых аудиториях доходит до трети. Поэтому первое открытие я храню отдельно от общего счётчика, а к абсолютным числам отношусь как к верхней оценке. Для транзакционных писем это не критично — там важен факт доставки, а не прочтения, — но если кто-то строит на этих числах воронку, стоит его предупредить.

DMARC-отчёты: единственное зеркало

К DMARC стоит вернуться отдельно, потому что его отчётную часть почти все игнорируют, а она — единственный канал обратной связи, который у вас вообще есть.

Когда в записи стоит rua=mailto:..., почтовые системы получателей начинают присылать на этот адрес агрегированные отчёты: сколько писем пришло от вашего домена, с каких IP-адресов, сколько прошло SPF, сколько DKIM, что было сделано с непрошедшими. Это XML внутри gzip-архива, приходит обычно раз в сутки от каждого крупного провайдера. Сырой XML читать невозможно, поэтому его либо загружают в готовый анализатор, либо разбирают у себя — у меня разбирает сервис и сводит в таблицу по источникам.

Внутри всё довольно читаемо, если знать, куда смотреть — вот кусок реального отчёта:

<record>
  <row>
    <source_ip>1.2.3.4</source_ip>
    <count>412</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>pass</dkim>
      <spf>permerror</spf>
    </policy_evaluated>
  </row>
  <identifiers><header_from>synapsea.agency</header_from></identifiers>
</record>

Вот этот permerror в spf при живом pass в dkim — ровно то, что я мог увидеть на сутки раньше, если бы отчёты были настроены.

Ценность в том, что это единственный способ увидеть свой домен глазами получателя. Из отчётов видно ровно три вещи, которые больше взять неоткуда: проходит ли аутентификация массово или у части писем, не шлёт ли кто-то посторонний письма от вашего имени с чужих адресов (характерный признак спуфинга — записи с незнакомыми IP и dkim=fail), и не сломалось ли что-то после изменения в DNS. Причём последнее видно на следующий день, а не через неделю, когда пожалуется клиент.

Есть ещё ruf — форензик-отчёты по каждому отдельному непрошедшему письму. Звучит полезнее агрегированных, но на практике их почти никто не шлёт: там персональные данные, и крупные провайдеры давно перестали, чтобы не нарушать законодательство о персональных данных. Так что рассчитывать стоит только на rua.

Без этих отчётов вы в положении, когда письма уходят в темноту: код 250 получен, что дальше — неизвестно до первой жалобы пользователя. Собственно, ровно в таком положении я и был, когда началась история с двумя SPF-записями. Если бы rua был настроен с самого начала, первый же ежедневный отчёт от Mail.ru показал бы spf=permerror по всем письмам, и вечер на перепроверку DKIM я бы не потратил.

Что в итоге

Движок собран: DKIM с каноникализацией по RFC и своим хранилищем ключей, автопроверка трёх записей в DNS при подключении домена, прогрев IP по лестнице лимитов, распределение по адресам с весом от репутации, suppression-лист, разбор DSN и DMARC-отчётов, HTTP API и SMTP-релей на 587.

Если бы я делал это заново, то поменял бы порядок работ. Начал бы не с реализации подписи, которая интересная, а с DMARC на p=none и настроенным rua — чтобы к моменту, когда пойдут первые письма, уже был канал, по которому видно, что происходит. И перед любой отладкой смотрел бы на домен целиком через dig +short TXT, а не на ту его часть, которую только что написал сам. Обе эти мысли стоили мне вечера, а выглядят как что-то, что должно быть в первом абзаце любого руководства.

Всё описанное работает в Synapsea Mail — это мой сервис транзакционной почты: HTTP API плюс SMTP-релей на 587, серверы в РФ, проверка SPF/DKIM/DMARC при подключении домена. API совместим с Resend по формату, так что если вы уже на нём сидите, переезд сводится к смене base URL. Есть бесплатный тариф на 3 000 писем в месяц, его хватает пет-проекту с головой.

Отдельно попрошу: если будете пробовать и поймаете расхождение в доставляемости — напишите мне, такие репорты сейчас ценнее любого фидбека по интерфейсу.

Ну и если у вас есть свои истории про письма, улетающие в спам без видимой причины, — расскажите в комментариях, особенно про Mail.ru и Яндекс: гайдов под них по-прежнему мало, а ведут они себя по-своему. И если кто-то делал DKIM руками и находил другие места, где он тихо ломается, мне интересно, я такое коллекционирую.

Процесс разработки пишу в личном канале — https://t.me/synapseaa.