Тема
ТЗ — 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_providers | type (mail/sms), driver, priority, is_active, cost_per_unit, currency_code, consecutive_health_failures, health_excluded_until | Реестр провайдеров, приоритет для failover, тариф и состояние авто-исключения по health |
cms_transport_delivery_log | provider, type, recipient_hash, status, sent_at, cost_amount | Статистика доставки (получатель — хеш); cost_amount — денормализованная стоимость на момент отправки |
cms_transport_suppression_list | recipient_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_active | FormRequest 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, TransportDeliveryFailed | payload по таблице раздела «События и обмен» |
Настройки (группа transport-providers)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
transport-providers.mail_primary | string | "smtp" | — | Основной провайдер почты |
transport-providers.sms_primary | string | "" | — | Основной провайдер SMS |
transport-providers.failover_enabled | bool | true | — | Автопереключение на резервный провайдер при отказе |
transport-providers.low_balance_threshold | int | 100 | — | Порог остатка SMS для уведомления о низком балансе |
transport-providers.rate_limit_per_minute | int | 60 | — | Лимит отправок в минуту на провайдера (троттлинг на своей стороне до лимита тарифа) |
transport-providers.provider_timeout_seconds | int | 10 | — | Таймаут запроса к API провайдера (через cms/integrations-bus) |
transport-providers.sending_enabled | bool | true | — | Kill-switch: аварийная остановка всей отправки (mail и sms) без выключения модуля |
transport-providers.health_exclude_after_failures | int | 3 | — | Подряд неудачных health-чеков провайдера, после которых он временно исключается из цепочки фейловера |
transport-providers.warmup_enabled | bool | false | — | Постепенное наращивание суточного лимита для новых email-провайдеров/доменов |
transport-providers.warmup_daily_limit_start | int | 50 | — | Стартовый суточный лимит писем на провайдера в режиме warm-up |
transport-providers.warmup_daily_limit_growth_percent | int | 20 | — | Прирост суточного лимита в день, пока warm-up активен |
transport-providers.api_keys | — | — | — | Секреты — только .env → config(), никогда в 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-providers | transport-providers.manage | Список подключённых провайдеров и их статус |
| PUT | /api/v1/admin/transport-providers/{id} | transport-providers.manage | Изменение приоритета/активности провайдера |
| GET | /api/v1/admin/transport-providers/{id}/stats | transport-providers.manage | Статистика доставки провайдера (keyset-пагинация) |
| GET | /api/v1/admin/transport-providers/suppression-list | transport-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/bounced | cms: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-transport | in | Дефолтный почтовый канал резолвит активного mail-провайдера; нет ни одного — log-fallback ядра |
cms/sms | provides-контракт sms-transport | in | cms/sms передаёт фактическую отправку сюда, сам отдаёт notification-channel шине уведомлений |
cms/notifications-bus | событие (TransportLowBalance, TransportDeliveryFailed) → notification-bus | out | Модуль издаёт факт, шина (если включена) резолвит получателя-админа и канал уведомления |
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-ключей — только .env → config(), никогда в 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- потоке.
Крайние случаи и типовые баги
- Провайдер вернул 5xx → фейловер на резервный провайдер того же типа транспорта, если настроен (
failover_enabled=true) и есть резервный сis_active=true; событиеTransportFailover. Резервного нет → сообщение остаётся в очереди на ретрай с backoff, после исчерпания попыток —TransportDeliveryFailedи запись вcms_transport_delivery_logсо статусомfailed(сообщение не теряется молча). - Достигнут rate limit провайдера → модуль троттлит отправку на своей стороне атомарным счётчиком до лимита тарифа (
rate_limit_per_minute); превышающие сообщения ждут своей очереди, а не отбрасываются и не шлются мимо лимита. - Email hard bounce → адрес помечается недоставляемым в
cms_transport_delivery_log(status=bounced), издаётсяTransportDeliveryFailed— модуль-отправитель узнаёт о недоставке; повторная отправка на этот адрес автоматически не создаётся (решение — за модулем-владельцем адреса). - Email soft bounce → штатный ретрай очереди с backoff, без отдельной эскалации; несколько soft bounce подряд на один адрес (порог) — переквалификация в
bounced. - Несколько реализаций
sms-transportподключены одновременно → активным считается провайдер с наименьшимpriorityиis_active=true; при равномpriority— детерминированный порядок поid(не случайный выбор), чтобы поведение было воспроизводимо в тестах и не мигрировало между деплоями. - Ни одного настроенного провайдера →
NotificationDispatchядра деградирует в log-fallback (сообщение фиксируется в логе, отправка не происходит, без 500); Filament показывает пустое состояние с подсказкой (см. «UX-требования»). - Модуль выключается посреди отправки → уже поставленные джобы очереди
transport-providersвыполняются штатно (очередь не принадлежит модулю целиком — переживает выключение); новые вызовы контракта отсутствуют — потребители (cms/sms, ядро) переходят на log-fallback, публичный сайт не падает (инвариант §0). - Таймаут внешнего API провайдера → вызов идёт через
cms/integrations-busсprovider_timeout_secondsи graceful fallback; таймаут трактуется как отказ провайдера и идёт по тому же пути, что и сценарий 1 (фейловер/ретрай), джоба не зависает. - Огромная пачка сообщений (массовая рассылка через
cms/email-marketing) → батчингBus::batchчанками под courtesy-лимиты провайдера, а не «залпом»; троттлинг из сценария 2 применяется и внутри батча, не только к одиночным отправкам. - Смена API-ключа провайдера посреди неотправленных сообщений в очереди → ключ читается из
config()в момент выполнения джобы, а не кешируется в payload джобы на момент постановки — неотправленные сообщения уходят уже с новым ключом; если старый ключ уже отозван у провайдера до выполнения джобы, это тот же путь, что и сценарий 1. - Провайдер отвечает 401/403 (невалидный/отозванный ключ) → не путается с временным отказом: health-чек модуля для этого провайдера падает явно («конфигурационная ошибка», не «недоступен»), но фейловер на резервный всё равно срабатывает — сообщение не теряется, админ видит точную причину.
- ⚠️ Противоречие (найдено и устранено в этой правке): раздел «События и обмен» до правки указывал контракты
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). - Изменился тариф провайдера (
cost_per_unit) → уже отправленные сообщения сохраняют исторический расчёт (cost_amountденормализован в момент отправки) — отчёт за прошлый период не пересчитывается задним числом при смене тарифа. - Провайдер проваливает health-чек N раз подряд (
health_exclude_after_failures) → исключается из фейловера ещё до реального отказа при отправке (health_excluded_until); первый успешный чек возвращает его автоматически. - Получатель в suppression-list → отправка не создаётся ни через один провайдер (список общий для всех), в журнале
status=suppressed,TransportDeliveryFailedне издаётся — осознанный пропуск, не отказ провайдера. - Warm-up: попытка превысить суточный лимит нового домена → сообщение не отбрасывается и не шлётся мимо лимита — ставится в очередь на следующие сутки.
- Диагностика 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запрещены.