2 100 писем и ноль ответов: разбор холодной рассылки в банки и фонды
В конце лета нам понадобилось задать вопросы специалистам по рискам в банках и инвестиционных фондах. Опрос был отраслевой: какими инструментами они пользуются, сколько работы держится на Excel, где буксуют процессы. Готовую базу мы решили не покупать и собрали адресатов сами, в основном из открытых реестров. За две недели ушло около 2 100 писем. Из холодной рассылки не ответил никто. Все десять ответов, которые у нас есть, пришли через знакомых.
Ниже разбор по шагам: откуда брали организации, как искали сайты и адреса, на чём споткнулась проверка DNS, почему почтовые шлюзы банков отбили первую волну и как рассылка однажды «успешно» не отправила ни одного письма. Код на Python, фрагменты взяты из нашего репозитория и местами упрощены.

Откуда брать организации: GLEIF
Нужен был список действующих финансовых организаций по многим странам, с сегментом (банк, управляющая компания, фонд) и адресом. Самый полный открытый источник такого рода называется GLEIF. Это глобальный реестр идентификаторов юрлиц (LEI). LEI нужен любой организации, которая заключает сделки на регулируемых финансовых рынках, так что финансовый сектор там представлен почти целиком. У GLEIF открытый JSON API без ключа.
Запрос по стране и ключевому слову:
GLEIF_API = "https://api.gleif.org/api/v1/lei-records" def gleif_page(country: str, keyword: str | None, page: int) -> list[dict]: params = { "filter[entity.legalAddress.country]": country, "page[size]": 200, "page[number]": page, } if keyword: params["filter[fulltext]"] = keyword # "bank", "asset management", ... else: params["filter[entity.category]"] = "FUND" return get_json(GLEIF_API, params).get("data", [])
Что пришлось учесть:
Полнотекстовый поиск ищет не только по названию. Запрос
bankпо ОАЭ вернул консалтинговую контору и торговую компанию: слово нашлось в адресе. Поэтому каждая запись проверяется второй раз, уже по самому названию, списком маркеров вродеbank,capital,asset,insur,banque,banca.Статус. Берём только
entity.status == ACTIVEи отбрасываем регистрации в статусеLAPSED: LEI не продлён, за записью никто не следит.Подфонды. У каждого подфонда паевой линейки свой LEI, но ни сайта, ни контактов отдельно от управляющей компании у него нет. Только категория
FUNDдала 21 056 записей. Отсекаем их по маркерамsub-fund,compartment,teilfondsи т. п.Материнская компания без лишнего запроса. GLEIF кладёт LEI родителя прямо в URL связи
.../lei-records/{LEI}/ultimate-parent-relationship. Его можно вынуть из ответа, а не делать отдельный запрос на каждую из десятков тысяч записей. Нужно это для дедупликации групп: писать в пять дочек одного банка бессмысленно.Юридический адрес не равен фактическому. Фильтр по стране работает по
legalAddress. У крупного банка из ОАЭ нашлась запись с юридическим адресом во Франции и штаб-квартирой в Абу-Даби. Если планировать отправку по юридическому адресу, письмо придёт в парижское утро человеку, который живёт по другому часовому поясу. Страну берём изheadquartersAddress, а юридический адрес используем только как запасной вариант.
hq = (ent.get("headquartersAddress") or {}).get("country") or "" legal = (ent.get("legalAddress") or {}).get("country") or "" real_country = hq or legal or queried_country
По 95 странам вышло около 27 тысяч живых организаций. После дедупликации по группам и потолка в 250 организаций на страну осталось 13 511. В дополнение к GLEIF по США брали EDGAR (реестр SEC) по финансовым кодам SIC. SEC требует User-Agent с контактом и держит предел 10 запросов в секунду. За нарушение банят по IP, и тогда источник пропадает целиком.
Реестр не знает сайта
У записи в GLEIF есть название, адрес и сегмент, но нет ни сайта, ни почты. Без сайта не найти адрес, поэтому дальше нужно сопоставить юрлицо с доменом.
Wikidata была первой попыткой: SPARQL-запрос пачками через VALUES с точным совпадением rdfs:label. На юридических названиях это почти не работает: в реестре FIRST ABU DHABI BANK P.J.S.C., в Wikidata First Abu Dhabi Bank. Второй проход отрезал юридическую форму с конца строки (Ltd, Inc, PJSC), но не трогал Group и Holding, потому что они бывают частью бренда. Два прохода вместе дали 220 доменов из 13 511, меньше 2 %.
Автокомплит Clearbit оказался на порядок полезнее. Публичный эндпоинт autocomplete.clearbit.com/v1/companies/suggest, который стоит у них на формах регистрации, на момент прогона отвечал без ключа. Он нашёл кандидата примерно для 30 % организаций. Но первому результату выдачи верить нельзя: ранжирование сделано под автокомплит, а не под точное сопоставление юрлиц. Для Angel Capital правильный домен стоял четвёртым, а первым шла Angel Capital Association, другая организация.
Лучшего кандидата выбираем по коэффициенту Жаккара на значимых словах. Родовые слова (capital, bank, fund, group, holding, ltd…) из сравнения выкинуты, иначе 424 Capital совпадёт с Capital One по одному слову capital:
def best_match(query_name: str, results: list[dict]) -> str: q = tokens(query_name) # без родовых слов best, best_score = "", 0.0 for cand in results: if not isinstance(cand, dict): # иногда в списке приходит строка continue c = tokens(cand.get("name", "")) if not (q & c): continue score = len(q & c) / len(q | c) # штраф за лишние слова кандидата if score > best_score: best, best_score = cand.get("domain", ""), score if best_score < 0.5 or best in MEGA_BRAND_DOMAINS: return "" return best
Проверка isinstance появилась после падения на 9 201-й организации из 13 291: на некоторых запросах автокомплит отдаёт в списке голую строку вместо объекта. А короткий список мегабрендов появился после AMAZON INSURANCE. Название совпало с кандидатом почти дословно, Жаккар такое не отсеивает, но amazon.be принадлежит ритейлеру, а не страховому брокеру.
Найденный домен остаётся кандидатом. Подтверждает его только следующий шаг: адрес, опубликованный на самом сайте.
Адрес, который никто не опубликовал
Часть базы досталась нам в виде готового списка из 1 909 адресов. В нём было всего 350 различных локальных частей, а info@ встречался 761 раз, 39,9 %. Это отпечаток списка, собранного по шаблону: берут домен компании и дописывают info@. Шесть таких адресов мы отправили вручную на пробу. Все шесть вернулись с 550 User Unknown.
Поэтому адрес больше не сочиняем, а берём только то, что компания опубликовала сама: mailto:-ссылки и адреса в тексте страниц контактов. Ничего не нашлось, значит, адреса нет. Догадкой пустое место не заполняем.
Результат отрезвляет. Из 1 682 обойдённых сайтов адрес нашёлся на 438, это 26 %. Самые частые из 1 301 найденного адреса показаны на графике.

Пресс-служба, отношения с инвесторами, кадры, жалобы, комплаенс. Адресов со словами risk, treasury, quant или portfolio среди них ноль. Люди, которых мы хотели спросить, свою почту на сайтах не публикуют. А отправлять опрос про инструменты риск-менеджмента на careers@ бессмысленно, поэтому появился фильтр неподходящих ящиков:
UNSUITABLE_LOCAL = frozenset({ "whistleblowing", "compliance", "complaints", "privacy", "dpo", "legal", "fraud", "security", "careers", "jobs", "recruitment", "hr", "noreply", "donotreply", "hse", "safety", ... }) def is_suitable(email: str) -> bool: local = email.split("@")[0].lower() squashed = local.replace(".", "").replace("_", "").replace("-", "") ...
Склейка без разделителей добавлена после do_not_reply@: разбор по первому сегменту давал стебель do, и адрес проходил фильтр. В список заглушек (firstname, yourname, example…) когда-то попал и mail. Это было ошибкой: у многих европейских банков mail@ и есть настоящий общий ящик.
Проверка домена и ловушка fake-ip
Прежде чем слать, стоит убедиться, что домен вообще принимает почту. Смотрим MX-запись, а если её нет, A-запись: по RFC 5321 при отсутствии MX почта идёт на сам хост (implicit MX).
for record in ("MX", "A"): out = run(["dig", "+short", "+time=3", "+tries=2", record, domain]) if out: return record, out[0] return "NONE", ""
В кэше этой проверки 1 680 доменов. У 1 616 нашлась MX, у 64 только A-запись. Все 64 адреса из этих A-записей лежат в диапазоне 198.18.0.0/15. Это не адреса почтовых серверов. Диапазон зарезервирован под тестирование сетевого оборудования (RFC 2544), и его же используют прокси в режиме fake-ip: они отвечают выдуманным адресом на любой A-запрос, чтобы потом перехватить соединение.
Проверка проста:
$ dig +short A nonexistent-zz9q7-test-domain-xyz.com 198.18.26.171 $ dig +short MX nonexistent-zz9q7-test-domain-xyz.com $
На машине, где работала проверка, стоял такой прокси. На MX-запросы он честно отвечал пустотой, а на A-запросы отвечал чем угодно. Поэтому «домен без MX, но с A-записью» на этой машине значит только одно: MX нет. Проверка, которая должна была отсеивать несуществующие домены, пропускала любой домен без MX, даже выдуманный. Вывод банальный, но мы его пропустили: прежде чем проверять DNS, проверьте свой резолвер на заведомо несуществующем имени.
Существует ли сам ящик, DNS не скажет. Для этого есть SMTP-диалог без отправки письма: EHLO, MAIL FROM, RCPT TO:<адрес>, QUIT. Вердиктом служит ответ на RCPT TO, а команда DATA не отправляется никогда. Наивную версию ломает catch-all: такой домен отвечает 250 на любой адрес. Поэтому на каждом домене спрашиваем второй, заведомо случайный адрес. Если ответы совпали, вердикт «проверить нельзя», а не «адрес существует»:
real = rcpt(mx, address) fake = rcpt(mx, f"{random_local(16)}@{domain}") if real == fake == 250: return CATCHALL return VALID if real == 250 else INVALID if real in (550, 551, 553) else UNKNOWN
Запускать это приходится там, где открыт исходящий 25-й порт. Домашние провайдеры и многие облака закрывают его по умолчанию.
Попутно из MX-записей видно, кто принимает почту на той стороне.

Почти две трети доменов обслуживают Microsoft 365 или облачные фильтры: Proofpoint, Mimecast, Cisco. Письмо от домена без репутации и с неаккуратной аутентификацией проходит через них с трудом. Это и проявилось в первой волне.
Первая волна: Gmail и DMARC
31 августа около 190 приглашений ушли из Gmail через «Отправлять как»: письмо уходит из вашего Gmail, но в поле From стоит адрес на своём домене. Для личной переписки это удобно. Для писем в банк не годится.

Что было не так:
DKIM. Gmail подписывает такое письмо своим доменом:
d=gmail.com. Домен в From при этом наш.Return-Path. Обратный адрес тоже на
gmail.com, поэтому SPF проверяется для gmail.com, а не для нашего домена. Сам SPF нашего домена разрешал только сервис входящей переадресации, про Gmail там не было ни слова.DMARC требует, чтобы домен в From совпадал с доменом из прошедшей проверки DKIM или SPF. Не совпал ни один.
Шлюзы нескольких банков отвечали 451 Temporary recipient validation error, три дня повторяли попытки и сдавались. Про второй класс отказов уже было выше: 550 на угаданные адреса.
Лечится это отправкой через сервис, который подписывает письма ключом вашего домена и ставит Return-Path на ваш поддомен. У нас это Resend, он работает поверх Amazon SES. Перед рассылкой полезно посмотреть на три записи:
$ dig +short TXT send.example.com # SPF поддомена, который стоит в Return-Path $ dig +short TXT resend._domainkey.example.com # публичный ключ DKIM $ dig +short TXT _dmarc.example.com # политика DMARC и адрес для отчётов
И отправить себе тестовое письмо: в заголовке Authentication-Results должны стоять dkim=pass, spf=pass и dmarc=pass. Ещё два пункта из практики. Адрес no-reply@ в From сам по себе похож на массовую рассылку, лучше живой адрес человека. А Gmail и Yahoo с 2024 года требуют от массовых отправителей рабочий List-Unsubscribe с отпиской в один клик (RFC 8058).
Вторая волна: расписание, квоты, идемпотентность
Остальные ~1 900 писем ушли через сервис рассылки: первые 200 с рабочей машины, дальше с сервера по расписанию. Несколько деталей, которые оказались важнее, чем казались.
Прогрев домена. У нового домена нет репутации, и две тысячи писем в первый день отправят его в спам. Суточные порции растут по лестнице:
WARMUP_RAMP = (50, 100, 200, 350, 500, 700, 900) # последнее значение повторяется
Локальное время получателя. Прогрев назначает день, а часовой пояс страны определяет час внутри дня. Первый и последний рабочий день недели исключены: в первый письмо тонет в накопившемся за выходные, в последний его читают вполглаза. При выходных в субботу и воскресенье письма уходят со вторника по четверг, а в странах, где выходные пятница и суббота, с понедельника по среду. Внутри окна письма разбросаны на 110 минут: залп из сотни писем в 09:30:00 фильтры узнают сразу. Сдвиг считается от хеша адресата, а не случайно, поэтому повторный расчёт расписания его не двигает.
Здесь мы поймали самый обидный баг кампании. Сначала день кампании прибавлялся к дате как календарный (start + timedelta(days=n)), а потом дата докручивалась вперёд до ближайшего подходящего дня. При трёх подходящих днях в неделе дни 3, 4 и 5 докручивались на одну и ту же дату, и 1 449 писем из 1 799 оказались назначены на одни сутки. Прогрев существовал только в отчёте. Считать надо подходящие дни, а не календарные:
def nth_sending_day(start: date, index: int, good_days: frozenset[int]) -> date: day, seen = start, 0 for _ in range(400): if day.weekday() in good_days: if seen == index: return day seen += 1 day += timedelta(days=1) raise RuntimeError(f"не нашёлся {index}-й подходящий день от {start}")
Квота списывается при постановке, а не при доставке. Письма уходят с отложенной доставкой (scheduled_at), и мы думали, что суточный лимит считается по дню доставки. Нет: 1 сентября сервис принял 200 писем, а следующие вернули 429 daily_quota_exceeded, хотя доставляться они должны были в другие дни. Теперь каждый запуск сначала считает, сколько уже поставлено за сегодня:
today_used = submitted_today() # по времени постановки, не доставки room = max(0, max_per_run - today_used) pool = pool[:room]
Идемпотентность. Пачка отправляется с ключом, посчитанным от её содержимого. Если сеть оборвалась посреди кампании и запуск повторили, сервис вернёт прежние id, а не отправит письма второй раз:
def idempotency_key(payloads: list[dict]) -> str: blob = json.dumps([(p["to"], p["scheduled_at"], p["subject"]) for p in payloads], sort_keys=True, ensure_ascii=False) return hashlib.sha256(blob.encode()).hexdigest()
Доля отказов. У SES при доле отказов выше 5 % аккаунт уходит на проверку, выше 10 % отправку могут остановить. Для этого написали отчёт по отказам, который надо запускать после каждой волны, до постановки следующей. С сервера он не работает, об этом ниже.
Успех, за которым нет работы

2 сентября ежедневный прогон завершился с кодом 0 и итогом «поставлено 0, сбоев 100». Ключ отправки на сервере оказался пустым. Таймер systemd отработал, сервис вышел без ошибки, мониторинг молчал.
Код возврата отвечает на вопрос «упал ли процесс», а нужен был ответ на другой: «ушли ли письма». После этого появился отдельный сторож. Раз в сутки он смотрит не на код возврата, а на след работы: сколько записей о постановке появилось в журнале за сегодня. Итог уходит сводкой в рабочий чат:
def main() -> int: sent = scheduled_today(log_rows()) # записи event=scheduled за сегодня (UTC) post_summary(sent, remaining()) return 0 if sent else 3 # ноль писем считаем отказом, даже если никто не упал
У такой проверки есть забавное следствие. Очередь кончилась 14 сентября, и с тех пор сторож каждое утро сообщает, что рассылка встала. По букве он прав.
У этого сторожа есть своя слепая зона, и мы в неё тоже попали. На сервере лежит ключ только с правом отправки, статусы доставки ему недоступны. Мы знаем, сколько писем поставили, и не знаем, сколько из них доставлено, отбито или ушло в спам. Про две тысячи писем без статусов почти нечего сказать, кроме того, что они были отправлены. Если бы начинали заново, первым делом подключили бы вебхуки о доставке и отказах.
Приём ответов: curl не заменяет браузер
Форма опроса лежит статической страницей на своём поддомене, ответы уходят POST-запросом на API. После переименования домена мы проверили приём curl-ом: 200, строка в базе, кириллица цела. Из браузера форма при этом не отправлялась вовсе: запрос блокировался CORS. В проде работал образ, собранный до переименования, и нового источника в списке разрешённых не было. В репозитории нужная строка уже была, но на прод её не выкатили.
curl политику источников не применяет, поэтому проверка была верной и бесполезной одновременно. Браузерную поверхность проверяем браузером, с чистого профиля: CORS, CSP, куки, preflight и редиректы curl не видит.
Вторая деталь приёма касается отчёта. Анкета обещает не публиковать срез, в котором меньше пяти ответов. Такие варианты сворачиваются в одну строку с суммой, а не удаляются молча: иначе доли перестанут сходиться к числу ответов, и читатель решит, что данных нет.
def suppress(distribution: dict[str, int], minimum: int = 5) -> dict[str, int]: kept = {code: n for code, n in distribution.items() if n >= minimum} hidden = sum(n for n in distribution.values() if n < minimum) if hidden: kept["__below_threshold__"] = hidden return kept
При десяти ответах под порог уходит почти всё. Поэтому результатов опроса в этой статье нет.
Почему никто не ответил
Точно мы этого не знаем: без статусов доставки невозможно отделить «не дошло» от «дошло и не заинтересовало». Гипотезы в порядке убывания уверенности:
Адрес не того человека. Опубликованные адреса ведут в пресс-службу, отношения с инвесторами, кадры и на общий ящик. Даже если письмо дошло, его читает не риск-менеджер, и пересылать его внутри банка некому и незачем.
Шлюзы. Две трети получателей принимают почту через облачные фильтры, а у нас новый домен без репутации. Первая волна к тому же провалила DMARC.
Письмо от незнакомой организации без имени отправителя. Приглашение в опрос от неизвестной команды относится как раз к тем письмам, которые люди в регулируемых организациях приучены не открывать.
Тёплый круг работает, холодный не работает. Шесть ответов за два дня дали бывшие коллеги. Холодная рассылка за две недели не дала ни одного.
Что бы мы сделали иначе
Настроить SPF, DKIM и DMARC и проверить их тестовым письмом до первой отправки, а не после разбора отказов.
Не отправлять ничего с ключом, который не видит доставку. Вебхуки о доставке и отказах подключить до старта.
Проверить резолвер на несуществующем имени до того, как доверять проверке DNS.
Мониторить результат работы, а не код возврата.
Смириться с тем, что публичные адреса ведут не к специалистам, и искать людей по имени и роли. Или честно считать холодную рассылку в регулируемые организации лотереей с очень плохими шансами.
А если у вас есть опыт, как достучаться до специалистов в банках и фондах, расскажите в комментариях. У нас он пока отрицательный.

