Skip to content

ТЗ — Email/SMS-провайдеры (транспорт) (cms/transport-providers)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Коннекторы транспорта доставки (Unisender, SMS.ru, SMTP-провайдеры) поверх cms/integrations-bus: модуль предоставляет каналы mail-transport и sms-transport для остальной системы (напрямую — ядро для дефолтной почты и cms/sms для SMS-канала, через них — в конечном счёте cms/notifications-bus), скрывая различия между провайдерами за единым интерфейсом и добавляя failover между ними.

  • Коннекторы Unisender, SMS.ru и произвольного SMTP через cms/integrations-bus;
  • provides: mail-transport, provides: sms-transport для потребителей (уведомления, рассылки);
  • failover: при отказе основного провайдера — автопереключение на резервный;
  • статистика доставки по провайдеру (успех/отказ/задержка);
  • проверка баланса SMS-провайдера (если API это поддерживает) с уведомлением о низком балансе;
  • ограничение частоты отправки per-провайдер (защита от блокировки аккаунта);
  • suppression-list (bounce/complaint) — общий список подавления адресов/номеров для всех провайдеров и модулей-отправителей;
  • warm-up новых email-доменов/IP — постепенное наращивание суточного лимита при подключении нового провайдера (защита репутации);
  • диагностика DKIM/SPF/DMARC для доменов отправки (только проверка, не управление DNS);
  • расчётная стоимость отправок в валюте (не только в штуках) на дашборде — тариф провайдера × объём за период.

Зависимости и выключение

requires: cms/integrations-bus · suggests: cms/notifications-bus · provides:mail-transport, sms-transport

При выключении почта возвращается на дефолтный SMTP из .env/config Laravel, SMS-канал уведомлений становится недоступен (уведомления по остальным каналам работают штатно).

Модель данных

ТаблицаКлючевые поляПримечание
cms_transport_providerstype (mail/sms), driver, priority, is_active, cost_per_unit, currency_code, consecutive_health_failures, health_excluded_untilРеестр провайдеров, приоритет для failover, тариф и состояние авто-исключения по health
cms_transport_delivery_logprovider, type, recipient_hash, status, sent_at, cost_amountСтатистика доставки (получатель — хеш); cost_amount — денормализованная стоимость на момент отправки
cms_transport_suppression_listrecipient_hash, reason (bounce/complaint/manual), suppressed_atОбщий список подавления адресов/номеров для всех провайдеров и модулей-отправителей

type/status/reason — enum → PHP Enum; is_active — bool cast; cost_per_unit — integer minor units (float запрещён, §4 стандарта), рядом currency_code (ISO 4217). consecutive_health_failures/health_excluded_until обновляются джобой health-чека («Фоновая работа»), не вручную. Журнальные таблицы — append-only, индекс (provider, sent_at) под статистику и стоимость за период, recipient_hash в suppression-list — уникальный индекс (быстрая проверка перед отправкой), кандидат на BRIN по sent_at/suppressed_at при большом объёме.

Входные и выходные данные

Входы (whitelist-принцип: всё остальное отвергается):

ИсточникДанные/поляЧем валидируется
Filament-форма провайдераtype, driver, priority, is_active (сами API-ключи — только .env, в форме не хранятся)FormRequest движка полей; ReDoS-валидатор ядра для любых пользовательских масок/regex
API PUT /api/v1/admin/transport-providers/{id}priority, is_activeFormRequest whitelist, права transport-providers.manage
Вызов контракта (in-process)recipient, body/template, type (mail/sms) от потребителя mail-transport/sms-transportТипизированный интерфейс cms/core-contracts (не строки/массивы произвольной формы)
Вебхук статуса доставки от SMS-провайдераprovider_message_id, status, errorЕдиная точка приёма cms/webhooks-in (подпись/секрет вебхука провайдера), не собственный публичный эндпоинт
Filament/API «снять из suppression-list»recipient_hash/причинаFormRequest, права transport-providers.manage
Импорт— (модуль не принимает импорт-файлы)

Проверка suppression-list — шаг до обращения к провайдеру: получатель в списке → status=suppressed в журнале, запрос к API не выполняется.

Выходы:

ПотребительДанныеФормат
cms/core (NotificationDispatch), cms/smsрезультат отправки (успех/ошибка) через контракт mail-transport/sms-transportсинхронный ответ контракта (bool/domain exception), сама доставка — асинхронно из очереди
Filament-админкасписок провайдеров, статус, статистика доставкиконверт {data, meta}, keyset-пагинация на /stats
cms/healthстатус health-чека по каждому активному провайдеруJSON (cms:transport-providers:doctor --json)
Подписчики событийTransportFailover, TransportLowBalance, TransportDeliveryFailedpayload по таблице раздела «События и обмен»

Настройки (группа transport-providers)

КлючТипДефолтaffectsPageCacheОписание
transport-providers.mail_primarystring"smtp"Основной провайдер почты
transport-providers.sms_primarystring""Основной провайдер SMS
transport-providers.failover_enabledbooltrueАвтопереключение на резервный провайдер при отказе
transport-providers.low_balance_thresholdint100Порог остатка SMS для уведомления о низком балансе
transport-providers.rate_limit_per_minuteint60Лимит отправок в минуту на провайдера (троттлинг на своей стороне до лимита тарифа)
transport-providers.provider_timeout_secondsint10Таймаут запроса к API провайдера (через cms/integrations-bus)
transport-providers.sending_enabledbooltrueKill-switch: аварийная остановка всей отправки (mail и sms) без выключения модуля
transport-providers.health_exclude_after_failuresint3Подряд неудачных health-чеков провайдера, после которых он временно исключается из цепочки фейловера
transport-providers.warmup_enabledboolfalseПостепенное наращивание суточного лимита для новых email-провайдеров/доменов
transport-providers.warmup_daily_limit_startint50Стартовый суточный лимит писем на провайдера в режиме warm-up
transport-providers.warmup_daily_limit_growth_percentint20Прирост суточного лимита в день, пока warm-up активен
transport-providers.api_keysСекреты — только .envconfig(), никогда в settings-store

Порядок фейловера — не отдельная настройка, а priority записи cms_transport_providers: при отказе активного берётся следующий по priority с is_active=true. sending_enabled (kill-switch) и failover_enabled (порядок при отказе) — независимые настройки, не путать. Стоимость единицы отправки (cost_per_unit) — не настройка группы, а поле провайдера в cms_transport_providers (тариф индивидуален) — см. «Модель данных».

API

МетодПутьДоступНазначение
GET/api/v1/admin/transport-providerstransport-providers.manageСписок подключённых провайдеров и их статус
PUT/api/v1/admin/transport-providers/{id}transport-providers.manageИзменение приоритета/активности провайдера
GET/api/v1/admin/transport-providers/{id}/statstransport-providers.manageСтатистика доставки провайдера (keyset-пагинация)
GET/api/v1/admin/transport-providers/suppression-listtransport-providers.manageПросмотр suppression-list (keyset-пагинация)
DELETE/api/v1/admin/transport-providers/suppression-list/{id}transport-providers.manageРучное снятие адреса/номера из suppression-list

Компоненты

Filament: ресурс провайдеров транспорта (приоритет, активность, статистика доставки, тариф cost_per_unit), виджет «баланс SMS», страница suppression-list (просмотр/ручное снятие, право transport-providers.manage), чеклист DKIM/SPF/DMARC доменов отправки (✅/❌ с подсказкой, что добавить — только диагностика, DNS создаётся в cms/domains/ proxy-контуре). Команды: cms:transport-providers:check-balance --json, cms:transport-providers:doctor --json (доступность провайдеров + DKIM/SPF/DMARC). Демо-провайдер (фиктивный, без реального API, детерминированный «успех») — сидер для /_gallery и playground, показывает список/приоритет/статистику без реальных ключей.

Мини-ранбук (§15):

СимптомПроверитьКоманда
Письма массово не доходятcms_transport_delivery_log по провайдеру, статусы failed/bouncedcms:transport-providers:doctor --json
SMS резко просел балансСобытие TransportLowBalance, расход за период на дашбордеcms:transport-providers:check-balance --json
Провайдер выпал из фейловера без явного отказаconsecutive_health_failures/health_excluded_until провайдераcms:transport-providers:doctor --json

Бэкап/рестор: в бэкап — cms_transport_providers, cms_transport_delivery_log, cms_transport_suppression_list (потеря suppression-list = риск повторной отправки на bounced-адрес). Реестр активного провайдера в кеше и счётчик warm-up не бэкапятся — пересобираются из БД сами, ручного действия после рестора не требуется.

События и обмен

СобытиеКогдаPayload
TransportFailoverОсновной провайдер отказал, переключение на резервныйtype, from_provider, to_provider
TransportLowBalanceБаланс SMS-провайдера ниже порогаprovider, balance
TransportDeliveryFailedПровайдер вернул ошибку доставкиprovider, type, error
TransportSuppressedПолучатель добавлен в suppression-list (bounce/complaint/вручную)recipient_hash, reason

requires: cms/integrations-bus — коннекторы провайдеров вызываются через шину интеграций, не напрямую. Реализует канонические контракты mail-transport и sms-transport. Прямые потребители — cms/core (NotificationDispatch, дефолтный почтовый канал) и cms/sms (SMS-канал: сам реализует notification-channel для cms/notifications-bus, транспорт берёт здесь) — шина уведомлений напрямую этот модуль не вызывает, только через посредника cms/sms. TransportLowBalance/TransportDeliveryFailed резолвятся в уведомление через notification-bus, если он включён. FilterBus не используется. Слушает: только собственные вызовы отправки от потребителей контрактов.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/core (NotificationDispatch)provides-контракт mail-transportinДефолтный почтовый канал резолвит активного mail-провайдера; нет ни одного — log-fallback ядра
cms/smsprovides-контракт sms-transportincms/sms передаёт фактическую отправку сюда, сам отдаёт notification-channel шине уведомлений
cms/notifications-busсобытие (TransportLowBalance, TransportDeliveryFailed) → notification-busoutМодуль издаёт факт, шина (если включена) резолвит получателя-админа и канал уведомления
cms/email-templatesсервис-вызов (suggests)inГотовый HTML письма (тема + тело) приходит от email-templates, модуль только доставляет, не рендерит
cms/integrations-busсервис-вызов (requires)outВсе HTTP-вызовы к API провайдеров идут через шину интеграций (таймаут, единое логирование, без прямого cURL)
cms/webhooks-inвебхукinСтатусы доставки SMS (доставлено/ошибка/bounce) приходят через единую точку приёма вебхуков ядра
cms/healthсервис-вызов (health-чек)outОтдаёт статус доступности каждого активного провайдера в общий дашборд здоровья

Фоновая работа

Очередь transport-providers: отправка письма/SMS — идемпотентная джоба (повтор при failover не создаёт дублей сообщения получателю), проверка баланса SMS-провайдера — по расписанию через ScheduleRegistrar ядра. Внешние вызовы к API провайдеров — только из очереди.

Health-чек (cms:transport-providers:doctor, по расписанию) при провале инкрементирует consecutive_health_failures; по достижении health_exclude_after_failures провайдер временно исключается из цепочки фейловера (health_excluded_until) — до первого успешного чека, который сбрасывает счётчик без ручного действия. Warm-up (warmup_enabled) — джоба по расписанию поднимает суточный лимит на warmup_daily_limit_growth_percent; счётчик отправленного за сутки сбрасывается ежедневно.

Производительность и кеш

Ожидаемые объёмы (ориентир для расчёта лимитов и индексов): email — от сотен до нескольких тысяч писем/сутки (транзакционные + рассылки через cms/email-marketing), SMS — до нескольких сотен/сутки (OTP + уведомления — дороже email, троттлится агрессивнее дефолтом), push вне зоны модуля (cms/web-push).

Горячий путь: резолвинг активного провайдера по типу транспорта на каждую отправку — 0 запросов к БД (чтение из кеша тега transport-providers.registry, контрактный тест); сама сетевая отправка асинхронна и никогда не выполняется в HTTP-потоке запроса (только из очереди transport-providers).

Критичные индексы: cms_transport_delivery_log(provider, sent_at) — журнал/график доставки за период (объявлен в «Модель данных»), cms_transport_delivery_log(provider, status) — доля отказов по провайдеру для дашборда; BRIN по sent_at при росте журнала.

Кешируется: тег transport-providers.registry — реестр подключённых провайдеров и их приоритет, инвалидация — событием сохранения провайдера в Filament (afterSave()); остаток лимита/баланса SMS-провайдера — короткий TTL (не тег: обновляется отдельной джобой проверки баланса по расписанию, не событием сохранения). Проверка suppression-list перед отправкой — уникальный индекс на recipient_hash, отдельный lookup, не JOIN — не влияет на бюджет запросов резолвинга провайдера.

Безопасность

Входные границы: Filament/API (transport-providers.manage — изменение приоритета/ активности), внешние вызовы к провайдерам — только из очереди с таймаутом. Секреты API-ключей — только .envconfig(), никогда в settings-store и никогда не логируются (в т.ч. в cms_transport_delivery_log — только хеш получателя, не адрес/номер в открытом виде). Дедупликация уведомления о низком балансе — по окну времени, чтобы не спамить многократно.

Конкретные векторы:

  • утечка API-ключей провайдеров — в логах ошибок и в тексте доменных исключений (в т.ч. отдаваемых наружу через cms:transport-providers:doctor --json) ключ обязан маскироваться до логирования, не только не сохраняться в БД;
  • спам через дыру в лимитах — троттлинг (rate_limit_per_minute) обязан быть атомарным на уровне очереди/кеша (Redis-счётчик), а не проверкой «прочитал-сравнил- записал» в PHP: при параллельных джобах не атомарная проверка даёт превышение лимита провайдера и риск блокировки аккаунта;
  • подмена номера/адреса отправителя — from-адрес/имя отправителя берутся только из настроек провайдера/шаблона (cms/email-templates), не из пользовательского ввода формы или payload вебхука.

Права: transport-providers.view, transport-providers.manage.

Матрица ролей:

Рольview (список, статистика)manage (приоритет, активность)
Администратор
Менеджер
Редактор
Studio

ПДн-паспорт: получатель (email/телефон) в cms_transport_delivery_log хранится только в виде хеша (recipient_hash), открытый адрес/номер модуль не удерживает — участие в «выгрузить/забыть по субъекту» (152-ФЗ) закрывается на стороне модуля-владельца адреса (например, cms/leads, cms/crm-connector), не здесь.

UX-требования

Админ:

  • пустое состояние списка провайдеров — подсказка «Добавьте провайдера транспорта: без него почта уходит через дефолтный SMTP, а SMS-канал недоступен» со ссылкой на форму добавления;
  • основной и резервный провайдер выбираются раздельно по типу транспорта (mail и sms не делят один приоритет);
  • ошибки — человеческим языком («SMS-провайдер вернул ошибку баланса», «Провайдер недоступен, отправка ушла на резервный»), не сырой текст исключения/HTTP-код;
  • подтверждение при смене активного провайдера, если в очереди transport-providers есть неотправленные сообщения через текущего («В очереди N сообщений — переключить провайдера сейчас?»);
  • дашборд-виджет: расход лимита/баланса по каждому активному провайдеру, доля отказов за период, время последнего успешного health-чека, расчётная стоимость за периодcost_per_unit × отправленных из cms_transport_delivery_log за окно, в валюте провайдера);
  • страница диагностики DKIM/SPF/DMARC — чеклист по каждому домену отправки, ✅/❌ с подсказкой, что добавить в DNS (сами записи создаются вне модуля);
  • suppression-list — таблица с причиной и датой, ручное снятие с подтверждением (последствие необратимо по смыслу — адрес снова начинает получать рассылки).

Посетитель: модуль не имеет публичного UI (инфра-слой, письма/SMS формируют другие модули) — раздел не применим напрямую; единственное касание — отправка всегда асинхронна из очереди, посетитель никогда не ждёт ответа внешнего провайдера в HTTP- потоке.

Крайние случаи и типовые баги

  1. Провайдер вернул 5xx → фейловер на резервный провайдер того же типа транспорта, если настроен (failover_enabled=true) и есть резервный с is_active=true; событие TransportFailover. Резервного нет → сообщение остаётся в очереди на ретрай с backoff, после исчерпания попыток — TransportDeliveryFailed и запись в cms_transport_delivery_log со статусом failed (сообщение не теряется молча).
  2. Достигнут rate limit провайдера → модуль троттлит отправку на своей стороне атомарным счётчиком до лимита тарифа (rate_limit_per_minute); превышающие сообщения ждут своей очереди, а не отбрасываются и не шлются мимо лимита.
  3. Email hard bounce → адрес помечается недоставляемым в cms_transport_delivery_log (status=bounced), издаётся TransportDeliveryFailed — модуль-отправитель узнаёт о недоставке; повторная отправка на этот адрес автоматически не создаётся (решение — за модулем-владельцем адреса).
  4. Email soft bounce → штатный ретрай очереди с backoff, без отдельной эскалации; несколько soft bounce подряд на один адрес (порог) — переквалификация в bounced.
  5. Несколько реализаций sms-transport подключены одновременно → активным считается провайдер с наименьшим priority и is_active=true; при равном priority — детерминированный порядок по id (не случайный выбор), чтобы поведение было воспроизводимо в тестах и не мигрировало между деплоями.
  6. Ни одного настроенного провайдераNotificationDispatch ядра деградирует в log-fallback (сообщение фиксируется в логе, отправка не происходит, без 500); Filament показывает пустое состояние с подсказкой (см. «UX-требования»).
  7. Модуль выключается посреди отправки → уже поставленные джобы очереди transport-providers выполняются штатно (очередь не принадлежит модулю целиком — переживает выключение); новые вызовы контракта отсутствуют — потребители (cms/sms, ядро) переходят на log-fallback, публичный сайт не падает (инвариант §0).
  8. Таймаут внешнего API провайдера → вызов идёт через cms/integrations-bus с provider_timeout_seconds и graceful fallback; таймаут трактуется как отказ провайдера и идёт по тому же пути, что и сценарий 1 (фейловер/ретрай), джоба не зависает.
  9. Огромная пачка сообщений (массовая рассылка через cms/email-marketing) → батчинг Bus::batch чанками под courtesy-лимиты провайдера, а не «залпом»; троттлинг из сценария 2 применяется и внутри батча, не только к одиночным отправкам.
  10. Смена API-ключа провайдера посреди неотправленных сообщений в очереди → ключ читается из config() в момент выполнения джобы, а не кешируется в payload джобы на момент постановки — неотправленные сообщения уходят уже с новым ключом; если старый ключ уже отозван у провайдера до выполнения джобы, это тот же путь, что и сценарий 1.
  11. Провайдер отвечает 401/403 (невалидный/отозванный ключ) → не путается с временным отказом: health-чек модуля для этого провайдера падает явно («конфигурационная ошибка», не «недоступен»), но фейловер на резервный всё равно срабатывает — сообщение не теряется, админ видит точную причину.
  12. ⚠️ Противоречие (найдено и устранено в этой правке): раздел «События и обмен» до правки указывал контракты mail-transport/sms-transport как потребляемые «в первую очередь cms/notifications-bus» — по факту notifications-bus.md не объявляет cms/transport-providers ни в requires, ни в suggests: SMS-канал резолвится через посредника cms/sms (сам потребляет sms-transport и отдаёт шине notification-channel), почтовый канал — напрямую через NotificationDispatch ядра. Формулировка исправлена на прямых потребителей; при реализации сверить с актуальными notifications-bus.md/sms.md — расхождение в эту сторону снова означало бы скрытую зависимость (анти-паттерн §standard).
  13. Изменился тариф провайдера (cost_per_unit) → уже отправленные сообщения сохраняют исторический расчёт (cost_amount денормализован в момент отправки) — отчёт за прошлый период не пересчитывается задним числом при смене тарифа.
  14. Провайдер проваливает health-чек N раз подряд (health_exclude_after_failures) → исключается из фейловера ещё до реального отказа при отправке (health_excluded_until); первый успешный чек возвращает его автоматически.
  15. Получатель в suppression-list → отправка не создаётся ни через один провайдер (список общий для всех), в журнале status=suppressed, TransportDeliveryFailed не издаётся — осознанный пропуск, не отказ провайдера.
  16. Warm-up: попытка превысить суточный лимит нового домена → сообщение не отбрасывается и не шлётся мимо лимита — ставится в очередь на следующие сутки.
  17. Диагностика DKIM/SPF/DMARC при отсутствующей записи → пункт чеклиста помечается ❌ с подсказкой, остальные проверяются штатно; частичное отсутствие записей не валит команду doctor --json.

Донорский код

Донор: — (новая разработка). Исторический журнал доставки прошлых интеграций не переносится — операционный лог с истёкшей ценностью (не влияет на текущий фейловер/ тариф), а не сущность для миграции: import-legacy не применимо, донора с боевыми данными нет.

Тесты и приёмка

  • [ ] Контрактные тесты: отказ основного провайдера триггерит failover без потери сообщения;
  • [ ] health-чек модуля проверяет доступность каждого активного провайдера отдельно;
  • [ ] деградация при выключении — почта работает на дефолтном SMTP, SMS-канал недоступен без падения остальных уведомлений;
  • [ ] секреты API-ключей не попадают в settings-store и не логируются;
  • [ ] статистика доставки не хранит получателя в открытом виде (хеш/маскирование);
  • [ ] уведомление о низком балансе не дублируется многократно (дедупликация по окну времени);
  • [ ] троттлинг атомарен — параллельные джобы не превышают rate_limit_per_minute (тест на гонку, не только последовательный вызов);
  • [ ] несколько реализаций sms-transport/mail-transport — выбор активного провайдера детерминирован (priority + is_active, стабильный тай-брейк по id);
  • [ ] bounce-обработка: hard bounce помечает получателя недоставляемым и не создаёт повторных попыток; soft bounce уходит в штатный ретрай без ложной эскалации;
  • [ ] таймаут провайдера и 401/403 обрабатываются разными путями (фейловер/ретрай vs явная конфигурационная ошибка health-чека);
  • [ ] расчёт стоимости за период — сумма cost_per_unit × отправленных из cms_transport_delivery_log совпадает с виджетом дашборда;
  • [ ] смена тарифа провайдера не пересчитывает cost_amount уже отправленных сообщений задним числом;
  • [ ] провайдер исключается из фейловера после health_exclude_after_failures подряд неудачных health-чеков и возвращается после первого успешного;
  • [ ] suppression-list: адрес/номер в списке не получает отправку ни через один провайдер, TransportDeliveryFailed не издаётся;
  • [ ] warm-up: превышение суточного лимита нового провайдера откладывает сообщение на следующие сутки, не теряет и не шлёт мимо лимита;
  • [ ] диагностика DKIM/SPF/DMARC отражает отсутствующую запись как ❌ и не падает при частичном отсутствии записей;
  • [ ] sending_enabled=false останавливает всю отправку (mail и sms) без выключения модуля;
  • [ ] контрактный набор cms-testing зелёный, пакет протестирован в testbench-изоляции;
  • [ ] feature-тест на каждый роут API; тестовая БД только transport-providers_test, migrate:fresh/refresh/reset запрещены.

Внутренняя база знаний студии