Письмо с кодом подтверждения ушло в 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, «Ещё» → «Служебные заголовки», и ищете 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-запись на домене должна быть ровно одна, и это не рекомендация, а прямое требование 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. Репутацию надо набирать постепенно, малым объёмом, с приличными метриками, и это называется прогревом.
У меня это устроено как лестница дневных лимитов, по которой адрес поднимается при хороших показателях:

Стадия | Лимит в день | Условие перехода |
|---|---|---|
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.