Search by

skeeks / cms-communication

skeeks-semenov

Communications for SkeekS CMS CRM: mailboxes, messenger bots, conversations, client notifications and mailings linked to leads, companies and clients

Package info

github.com/skeeks-cms/cms-communication

Homepage

Issues

Wiki

Type:yii2-extension

pkg:composer/skeeks/cms-communication

Statistics

Installs: 38

Dependents: 4

Suggesters: 0

Stars: 0

1.19.7 2026-10-30 18:34 UTC

README

skeeks/cms-communication собирает переписку с клиентами в CRM: почтовые ящики, боты мессенджеров, уведомления клиентам и рассылки. Каждое письмо и сообщение связывается с лидом, компанией, клиентом или любой другой сущностью CRM и появляется в их ленте рядом со звонками, задачами и комментариями.

Версия 1.0 — почта. Боты, уведомления клиентам и рассылки — следующие этапы. Устройство коммуникаций — в ARCHITECTURE.md, план, этапы и решения по CRM — в плане платформы, правила разработки — в AGENTS.md, изменения — в CHANGELOG.md.

Каналы подключаются отдельными пакетами-драйверами:

  • skeeks/cms-communication-imap — почтовые ящики по IMAP/SMTP на любом сервисе.

Установка

  1. composer require skeeks/cms-communication skeeks/cms-communication-imap, затем php yii cms/migrate.

  2. Задать ключ шифрования паролей ящиков — длинную случайную строку — в защищённых параметрах проекта или в окружении:

    // params, не попадающие в репозиторий
    'cms-communication' => ['secretKey' => '...'],

    или переменная окружения CMS_COMMUNICATION_SECRET_KEY. Без ключа пароли сохранить нельзя. Если ключ сменить, сохранённые пароли перестанут расшифровываться — их придётся ввести заново.

Что есть в админке

  • Настройки → Почтовые ящики — одна форма: адрес, имя отправителя, логин и пароль приложения. Логин по умолчанию равен адресу, серверы IMAP/SMTP подставляются сами для Яндекса, Mail.ru, Gmail, Rambler, Outlook и для корпоративных доменов на их почте (по MX). Пароль после сохранения не показывается; пустое поле при правке оставляет прежний. В карточке ящика — «Проверить подключение», «Получить письма», вкладка «Доступ сотрудников»: чтение (все письма / только связанные с его лидами, компаниями и клиентами / нет), отправка (на любой адрес / только своим / нет), ящик по умолчанию, управление ящиком, ответственный; вкладка «Сбор писем» — собрать письма за больший срок (до всех писем ящика), они считаются прочитанными. У ящика могут быть свои настройки хранения файлов.

  • Настройки → Настройки почты: «Хранение файлов» (по умолчанию «Минимальный»: картинки и вложения остаются в ящике и загружаются при открытии; «Экономный», «Полный», «Выборочно», предел размера, сроки хранения копий), «Сохранить файлы в CRM» (загрузить файлы уже полученных писем насовсем), «Очистка файлов» (удалить копии по условиям).

  • Редкий случай — несколько адресов приходят в один логин (алиасы, пересылка в папки): в форме ящика выбрать «Письма приходят в другой ящик». Подключения отдельно доступны по адресу cmsCommunication/admin-cms-comm-connection.

  • Почта (главное меню) — как почтовая программа: слева папки «Все письма», «Мои клиенты» (привязанные к лидам, компаниям, клиентам), «Без привязки» и «Входящие»/«Отправленные» каждого ящика с чтением «всё»; в середине список, справа письмо с ответом. Поиск по всей переписке: находится переписка, в любом письме которой есть слова запроса (отправитель, получатели, тема, текст). У каждого ящика также «Архив», «Спам» и «Корзина». Отметки «прочитано» у каждого сотрудника свои; на телефоне — одна колонка. Вкладка «Таблица» — прежний список. Папка «Черновики»: начатое письмо сохраняется само, с файлами, и дописывается позже; черновик ответа продолжается кнопкой «Ответить». Неотправленные черновики удаляются через срок из «Настроек почты» (по умолчанию 90 дней).

  • Разбор писем: в письме — «Привязать» (письмо или всю переписку к лиду, компании, клиенту или сделке, выбор с поиском), «×» у привязанной записи (отвязать письмо или всю переписку — тогда новые письма к ней сами не привяжутся), «Создать ▾» — задача, лид или сделка в стандартном окне CRM, заполненном из письма; у письма появляется отметка «Задача №…». То же — в меню строки списка (правая кнопка мыши или «⋮»). Требует «создания из источника» в skeeks/cms. «Ответственный» за переписку (меню, папка «Мне назначено», фильтр) и фильтр «Привязка» по умолчанию скрыты; включаются communication.showAssignee и communication.showLinkedFilter.

  • Администратор в «Почте» видит, пишет и управляет письмами по своим строкам доступа к ящикам, как любой сотрудник.

  • Действия в ящике (право «Управление письмами» в доступе к ящику): «Удалить», «В архив», «Это спам» / «Не спам», «Восстановить», «В папку» (папки самого ящика) и цветные метки — для письма, выбранных писем или всех писем папки. Письмо сразу уходит из папки, а в почтовом ящике переносится фоновой задачей; если сервер отказал, письмо остаётся на месте с причиной. Письма, перенесённые или удалённые в самой почте (например, в Яндексе), при следующем приёме оказываются там же и не задваиваются.

  • Мои настройки почты (ссылка под папками) — у каждого сотрудника свои: уведомления о новых письмах по каждому ящику (в колокольчике CRM; несколько писем одного приёма — одним уведомлением), подпись для каждого ящика, шаблоны быстрых ответов — личные и общие для ящика. Шаблон вставляется кнопкой «Шаблоны» в форме письма.

  • Написать письмо — кнопка у email в карточках лида, компании и клиента. Отправитель — ящики, с которых сотрудник может писать; письмо попадает в ленту карточки.

  • В карточке подключения — «Проверить подключение» (сразу входит на IMAP и SMTP и показывает ответ серверов) и «Получить письма».

  • Отправить клиенту — окно, которое открывают другие пакеты (КП, позже счета) у своего документа. Наверху — что отправляется: название, личная ссылка и файлы. Ниже — контакты клиента из CRM (email и телефоны лида, его компании и клиента, людей компании, компании и клиента сделки; только записи, которые сотрудник видит). Щелчок по контакту выбирает канал и получателя. «Письмо» — обычная форма письма из ящика сотрудника с текстом, ссылкой «Открыть …» (в письме это кнопка) и файлами вложением; письмо попадает в переписку, ленту и «Отправленные». «СМС» — текст, который можно поправить, и предпросмотр: в конце добавляется личная ссылка. СМС уходят через провайдера СМС сайта («Настройки → SMS провайдеры», основной или первый) и видны в ленте лида, компании, клиента.

  • Прикрепить из… в форме письма — меню документов установленных пакетов («КП», «Счёт»). Выбранный документ ложится в текст ссылкой (её можно передвинуть) и в письмо файлами; убрать — «×» у документа. Черновик его помнит.

  • Настройки → Темы рассылок — темы рекламных рассылок сайта («Акции и скидки», «Новости студии»…): название, описание для клиента, активна. Кнопка «Добавить начальные темы» добавляет четыре стандартные темы, которых ещё нет. У каждой рассылки одна тема; клиент отписывается от тем.

  • Рассылки в карточках лида, компании и клиента: блок «Рассылки» — у каждого email и телефона состояние по темам (подписан, на каком основании, отписан, стоп-лист). Кнопка у контакта открывает «Подписки на рассылки»: отметить согласие («подписался на сайте», «дал согласие устно», «нет согласия — холодный контакт»), отписку или стоп-лист с комментарием. Согласие и отписка хранятся у самого адреса или номера: один адрес лида, компании и клиента — одна отметка. У контакта в стоп-листе в карточке красная метка.

  • В окнах «Написать» и «Отправить клиенту» — предупреждение, если адрес или номер отписан от всех рассылок, пожаловался или не принимает письма. Личное письмо при этом уходит: отписка касается только рекламы, служебные и личные письма она не блокирует.

  • Лиды → вкладка «Импорт» (рядом с «Отчётом») — список получателей из CSV (UTF-8 или Windows-1251, разделитель — точка с запятой, запятая или табуляция) или таблица, скопированная из Excel: колонки → ответственный, тема рассылки и основание согласия для всего файла → предпросмотр с повторами (email, телефон, ИНН, название среди лидов, компаний и клиентов, повторы внутри файла) → импорт. Новые лиды — с источником «Рассылка» и ответственным, значения файла — в описании лида; ответственный получает одно уведомление. Тема и основание отмечаются у контактов и новых лидов, и повторов; отписку клиента импорт не снимает, настоящее согласие не превращает в «холодное». Больше 200 строк импортируется в фоне. Отчёт — по каждой строке. На вкладке — стандартный список загруженных таблиц (сотрудник видит свои, администратор — все) и кнопка «Загрузить таблицу»; загрузка и карточка импорта (колонки, предпросмотр, отчёт) открываются в стандартном окне поверх вкладки, список обновляется, когда окно закрывают.

  • Рассылки (главное меню, сразу под «Почтой») — кампании: письмо по теме рассылки группе лидов (по фильтру — статус, этап, источник, ответственный — или лиды загруженной таблицы «Лиды → Импорт»). Тема письма и текст с подстановками {Имя}, {Компания}, {Менеджер}, {Ссылка} (кнопка на предложение), {Отписаться}; предложение — без него, один документ всем (общая Презентация, у каждого своя ссылка) или каждому своё КП из шаблона (создаётся, когда уходит его письмо). Отправитель — ящик ответственного за лида или выбранный ящик. Карточка черновика: кто получит письмо, кто исключён и почему, с каких ящиков и примерно за сколько дней; предпросмотр на любом получателе; «Отправить тестовое письмо себе»; «Запустить». Запущенная кампания: пауза, продолжить, отменить; итоги в процентах и отчёт по каждому получателю (отправлено, ошибка, открыл предложение, ответил, принял КП, отписался). Настройки «Задача менеджеру, когда клиент открыл предложение / ответил». Получатели по одному и личные письма: «+ Добавить получателя» — лид, компания (её email или email человека компании) или клиент; у каждого получателя может быть своё письмо — тема и текст вместо шаблона (обязательны {Отписаться} и, если есть предложение, {Ссылка}), видно, кто его подготовил (сотрудник или ИИ и через что). Такие получатели входят в любую аудиторию; аудитория «Только добавленные получатели» — только они. Письмо получателя, который ещё ждёт отправки, можно поправить и после запуска. Право «Рассылки: создавать и запускать кампании» (cmsCommunication/admin-cms-comm-campaign), у администраторов есть сразу.

  • Почтовый ящик → вкладка «Рассылки» — скорость писем кампаний с ящика: писем в день (по умолчанию 30, вместе с личными письмами менеджера за день — из CRM и из самой почты), пауза между письмами (случайная, по умолчанию 60–180 секунд), рабочие часы и дни (по умолчанию пн–пт 9–18); сколько ушло сегодня, сколько ждёт и когда следующее письмо.

Ящиками и подключениями пока управляют администраторы.

Рассылки: согласие, отписка, проверка перед отправкой

  • Контакт рассылок — models\CmsCommContact (сайт, вид email/phone, значение в нижнем регистре или цифрами 79991234567, код личной ссылки). Состояние по теме — CmsCommContactTopic, стоп-лист — CmsCommSuppression. Пишет их только services\MailingConsents (subscribe, unsubscribe, suppress, unsuppress, state, summary).
  • Можно ли отправить рекламу: services\MailingPolicy::check($siteId, 'email'|'sms', $value, $topicId, MailingPolicy::MODE_MANAGER|MODE_SERVICE) и checkMany() для списка — ['allowed', 'reason', 'message', 'consent']. Ящик менеджера (manager) может писать «холодным» контактам и контактам без отметок; сервис рассылок (service) — только при согласии. Причины отказа: нет контакта, тема выключена, отписан от всех, жалоба, не доставляется, отписан от темы, нет согласия. Личные и служебные письма эту проверку не проходят; MailingPolicy::warnings() — для предупреждения в окнах.
  • Письмо рассылки: ComposeForm::$cms_comm_topic_id (задаётся кодом кампании, из запроса не загружается) — одному получателю без копий, проверка политики, ссылка «Отписаться» шаблона https://sx-unsubscribe.invalid/ (Unsubscribe::PLACEHOLDER) становится личной ссылкой получателя, без неё в конец добавляется строка «Отписаться от рассылки»; у отправленного письма заголовки List-Unsubscribe и List-Unsubscribe-Post (RFC 8058).
  • Страница отписки /~unsub/<код>?t=<тема>&m=<письмо> без входа, не индексируется: темы галочками, «Отписаться от всех» и «по всем каналам» (все адреса и телефоны лидов, компаний и клиентов этого контакта). POST на тот же адрес с List-Unsubscribe=One-Click — отписка от темы письма (от всей рекламы, если темы в ссылке нет) кнопкой почтовой программы.
  • Импорт: services\LeadImport (create, preview, run), повторы — services\CrmDuplicates, задача comm.import.leads. Строки импорта (CmsCommImportRow: создан лид, повтор какой записи, пропущено и почему) — отчёт и будущая аудитория кампании. Сделка импорта всегда с клиентом: новому создаётся карточка компании или человека, известный (которого видит автор импорта) привязывается, для клиента другого менеджера сделка не создаётся.

Кампании рассылок

  • Кампания — models\CmsCommCampaign, получатели и отчёт — models\CmsCommCampaignRecipient (одна строка на лида: уникальный ключ кампания + запись CRM, поэтому одному лиду кампания пишет один раз). Всё делает services\Campaigns: summary() (аудитория через forManager() запускающего, первый email лида, исключения с причиной: MailingPolicy::checkMany() в режиме канала, повтор адреса, закрытый лид из импорта, allowedRecipients, нет ящика), launch() (получатели фиксируются один раз), pause(), resume(), cancel(), preview(), sendTest(), deliver() (одно письмо: повторная проверка политики, письмо и предложение в одной транзакции с отметкой получателя), refresh() и stats() (отчёт), taskOnOpen(), onIncoming() (ответы).
  • Получатели по одному (сотрудник в карточке, ИИ через MCP): Campaigns::addRecipient($campaign, $record, $user, ['address', 'name', 'subject', 'body_html', 'by' => 'user'|'ai', 'client' => 'crm'|'mcp:<клиент>']) — до запуска, запись (лид, компания, клиент) должна быть видна сотруднику, адрес — один из Campaigns::recordAddresses(); строка получателя в статусе draft с личным письмом и отметкой, кто его подготовил (text_by, text_client, text_user_id, text_at). setText() меняет письмо, пока оно не ушло (пустые тема и текст — снова шаблон), removeRecipient() убирает до запуска, textErrors() проверяет текст. При запуске добавленные объединяются с аудиторией (их адрес, имя и письмо главнее) и проходят те же исключения. Отправитель компании и клиента — их первый менеджер (Campaigns::responsibleOf()).
  • Каждое письмо — обычное письмо MailComposeService::queue() с ComposeForm::$cms_comm_topic_id (личная отписка, List-Unsubscribe), $cms_comm_campaign_id (отметка cms_comm_message.cms_comm_campaign_id), $record (лид — связь и запись CRM отправки, без поиска по видимости в задаче) и $sendableInstances (документ, сделанный кодом): лента лида, «Отправленные», ответ привязывается как у любого письма. SendContext::$campaignId говорит пакету документа, что отправка — из кампании.
  • Канал кампании — communication.campaignChannels (campaign\CampaignChannelInterface: policyMode() для MailingPolicy, assignSenders() при запуске, start(), deliver(CampaignLetter)). В пакете — campaign\MailboxCampaignChannel («Ящики менеджеров», MODE_MANAGER): задача comm.campaign.mailbox на ящик (resourceKey ящика, skip) отправляет одно письмо и ждёт в очереди случайную паузу или начало следующего рабочего периода (services\MailboxPace, дни и часы — в часовом поясе форматтера CRM); расписание comm.campaign.tick раз в 5 минут продолжает идущие кампании и завершает пустые. Сервис рассылок — отдельный пакет-драйвер со своим каналом MODE_SERVICE (skeeks/cms-communication-unisender, раздел «Сервисы рассылок и события доставки»); MailboxCampaignChannel не отправляет через ящик сервиса.
  • Предложение — источник отправляемого с send\CampaignSourceInterface: sharedItems() (документ всем), templates() и createFromTemplate() (документ каждому, в транзакции письма), states() (открыл, принял — для отчёта). Реализация — skeeks/cms-proposal. Правило «задача при первом открытии» остаётся в пакете документа и спрашивает Campaigns::taskOnOpen(); задачу по ответу ставит services\CampaignTasks.
  • Ссылки писем, сделанных в фоновой задаче, строятся от CommunicationComponent::siteUrl(): адрес веб-запроса, иначе communication.baseUrl, иначе главный домен сайта. Стенд с копией боевой базы задаёт baseUrl своего адреса.
  • Новые настройки скорости ящика действуют со следующего шага задачи: ящик, уже ждущий следующего дня, начнёт по ним с утра.

Сервисы рассылок и события доставки (этап 4C)

Сервис рассылок (Unisender Go и аналоги) — драйвер подключения с mail\MailingServiceInterface (только отправка) в отдельном пакете. Его ящик — обычный почтовый ящик CRM («Почтовые ящики» → «Подключение к серверу» → «Сервис рассылок … — только отправка», forms\MailboxConnectionForm service:<драйвер>): адрес — отправитель, права — «Доступ сотрудников». Через него уходят письма его кампаний, а также любые письма, которые сотрудник отправит с этого ящика («Написать», «Отправить клиенту») — общим MailComposeService::queue() и задачей comm.mail.send.

  • mail\MailingServices — какие драйверы сервисы рассылок; приём почты, «Получить письма» и рабочий ящик сотрудника (MailboxCampaignChannel::mailboxOf()) их пропускают, отправка не кладёт копию в «Отправленные».
  • Папки «Почты» (services\MailFolders::boxes()): у ящика сервиса рассылок — только «Отправленные»; ящик, с которого сотруднику можно только отправлять (право отправки без чтения ящика), тоже виден с одной папкой «Отправленные» — в ней его письма и письма его клиентов (forManager()). Переносить письма ящика сервиса нельзя (MailboxActions::manageableAccountIds() его не включает), а в папках ящика, которым сотрудник не управляет, нет кнопок «Удалить», «В архив», «Это спам», «В папку» и меток.
  • MailSendException::retryLater() — сервис ничего не принял и просит подождать (429): письмо снова queued, та же задача ждёт в очереди (requestRequeue), не дольше MailSendJobHandler::RETRY_HOURS часов от создания письма. Неизвестный исход (5xx, обрыв) повторно не отправляется.
  • services\DeliveryEvents::record(mail\DeliveryEvent) — единственный писатель событий доставки (cms_comm_message_event, один раз на сервис и ключ события): состояние письма delivery_status по первому адресу «Кому» (delivered, deferred, bounced, complained; ранг не понижается), delivered_at/bounced_at получателя кампании, жёсткий отказ живого адреса (addressDead) и жалоба — в стоп-лист с причиной (CmsCommSuppression::SOURCE_PROVIDER), отписка по ссылке сервиса — отписка от темы письма (CmsCommContactTopic::SOURCE_PROVIDER), без темы — от всех рекламных. Последствия — только если адрес события есть среди получателей письма. «Доставлено» видно в письме (раскрывается список событий) и в отчёте кампании (плитки и фильтры «Доставлено», «Не доставлено»; у канала «Ящики менеджеров» их нет — почтовый сервер о доставке не сообщает).
  • Ответ на письмо сервиса: сервис даёт письму свой Message-ID, поэтому MailIngestService при неизвестном родителе ищет наше письмо из ящика сервиса этому автору с той же темой (без «Re:») за 60 дней.

Списки получателей (этап 4D, шаг 1)

  • Список — models\CmsCommList (название, описание), участник — models\CmsCommListMember: запись CRM (лид, компания, клиент — человек компании), не адрес; канал выбирает контакт при отправке (письмо — первый email записи, у компании — свой, иначе её людей). У участника — кто (added_by), когда, откуда (added_via: manual, section + раздел, import + import:N, mcp + клиент) и «убран» (removed_at, removed_by): строка остаётся историей, повторное добавление её возвращает. Согласие и отписка — у контакта (4A): отписанный остаётся в списке и исключается при отправке с причиной.
  • Одна операция для интерфейса, импорта и будущих MCP-инструментов — services\RecipientLists: create(), add($list, $record, $user, $via, $ref, $checkVisible) (видимость через CrmRecords; запись, уже взятая запросом раздела, не проверяется второй раз), addMany() (отказы с причиной), remove(), listsOf(), members() (активные, каждый раз), addresses() и companies() (много записей за несколько запросов), stats() (участники, можно письмом, стоп-лист, без email), exportRows() (CSV).
  • Интерфейс: вкладка «Списки» раздела «Рассылки» (controllers\AdminCmsCommListController, стандартный список в вкладке AdminCmsCommCampaignController), карточка списка со счётчиками, участниками и историей, «Добавить» (окно выбора записи, добавление — POST), «убрать»/«вернуть», «Выгрузить CSV» (UTF-8 с BOM, ;). В карточках лида, компании и клиента — блок «Списки рассылок» (CrmCardBlocksWidget) с «+ Добавить в список» (окно add-record, выбранный или новый список), в меню «Действие» лида — «Добавить в список рассылки». Массовое действие «В список рассылки» в разделах «Компании», «Лиды», «Клиенты» — BackendModelMultiWindowAction через actionsMap контроллеров ядра (CommunicationBootstrap, helpers\ListUi::bulkAction()): отмеченные записи или «для всех» по стандартным фильтрам раздела, записи заново читаются через forManager() раздела. Импорт (4A): поле «Список рассылки» — существующий или новый список «Импорт №N» (создаётся при старте), в него попадают новые лиды и найденные повторы (лид, компания, клиент).
  • Кампания: аудитория «Списки получателей» (audience.lists) и для любой аудитории «Исключить списки» (audience.exclude_lists; компания в исключающем списке исключает и своих людей, и свои лиды). Получатели по одному (4B) по-прежнему дополняют аудиторию.
  • Бережная рассылка (Campaigns::protect(), причины в сводке): частота на контакт — components\MailingSettings::$frequencyDays (настройка сайта, вкладка «Настройки» раздела «Рассылки», по умолчанию 7): письмо другой кампании за N дней или ожидание в идущей — «Недавно получал рассылку»; «Не мешать менеджеру» — cms_comm_campaign.quiet_days (14 у новой кампании, 0 — выключено): письмо или сообщение в ленте записи или её компании, чат, привязанный к ним, звонок телефонии за N дней (письма рассылок не считаются); «Одна компания — одно письмо» — one_per_company: компания и её люди и лиды получают одно письмо — добавленный по одному со своим адресом, иначе сама компания, иначе первый. Частота и «не мешать менеджеру» проверяются ещё раз перед каждым письмом (с момента запуска).
  • «Готовый фильтр → Нет оплат» раздела «Компании» — в ядре skeeks/cms (нет поступлений cms-shop и оплаченных счетов компании).

Правила пополнения (шаг 2)

  • Правило — models\CmsCommListRule: фильтр раздела «Компании», «Лиды» или «Клиенты», сохранённый со страницы раздела галочкой «Пополнять по этому фильтру» в массовом действии «В список рассылки» (окно берёт адрес страницы раздела: значения findex[...] поверх сохранённого отображения sx-bs). Своего конструктора условий нет.
  • services\ListRules считает правило кодом самого раздела: создаёт контроллер раздела, применяет к запросу on init сетки его действия index (forManager()) и обработчики on apply явных полей фильтров — как QueryFiltersWidget::run(). Считается от имени сотрудника, сохранившего правило (asUser(): свежий компонент user, в консольной задаче — тоже). Поля «с режимом» (FilterField: статус, этап, источник лида и т. п.) создаются тем же классом, что даёт им страница, и применяют себя сами. Поля, которых нет в явной настройке фильтров раздела, правилом не бывают.
  • Как хранится правило: раздел и сырые значения фильтров страницы, как в адресе ({"categories":["2"],"custom":"no_payments"}, {"status":{"mode":"eq","value":[["new"],""]}}), плюс текст для людей. Смысл фильтра не хранится: каждый пересчёт идёт текущим кодом раздела, так что доработка фильтра без смены ключа и значений сразу действует и в правилах. Перед каждым пересчётом правило сверяет ключи и варианты списков выбора с текущими фильтрами раздела: переименованный фильтр или пропавший вариант останавливает правило с причиной (иначе раздел молча пропустил бы условие и правило расширилось бы). Меняя ключи или значения фильтров разделов «Компании», «Лиды», «Клиенты», переносите сохранённые правила.
  • run() добавляет только записи, которых в списке никогда не было (RecipientLists::addFound(), added_via = rule, rule:N): убранного вручную правило не вернёт, переставшая подходить запись остаётся. Ошибка остаётся у правила (last_error), отключённый сотрудник останавливает правило.
  • Когда пересчитывается: сразу при сохранении; задача comm.list.rules (расписание cmsAgent раз в час); перед сводкой кампании (если не считалось 15 минут) и при запуске кампании (Campaigns::refreshLists()) — для списков аудитории и исключающих; кнопка «Обновить сейчас» в карточке списка (там же «Выключить», «Удалить» — добавленные записи остаются).

Отправить клиенту: контракт для пакетов

Пакет, который отправляет клиентам свои документы, ничего не знает о каналах. Он описывает «отправляемое» и регистрирует источник:

// config пакета
'components' => ['communication' => ['sendables' => ['proposal' => ProposalSendableSource::class]]],
  • send\SendableSourceInterface (тип [a-z][a-z0-9_-]*, ссылка на документ — тип:id, например proposal:12): title() — название в меню («КП»); find($id, $user) — документ, если сотрудник может его отправлять, иначе null (права решает пакет); suggest($user, $q, $record, $limit) — список для «Прикрепить из…» (id, title, hint), документы карточки $record первыми.
  • send\SendableInterface — сам документ:
    • getSendTitle() — название (тема письма, подпись в форме);
    • getSendText() — короткий текст, который сотрудник может поправить (без оформления: так он уходит в СМС); если у документа текст с оформлением, он дополнительно реализует send\SendableHtmlInterface (getSendHtml()), и письмо начинается с этого HTML;
    • getSendButton() — надпись кнопки/ссылки («Открыть КП»);
    • getSendFiles() — send\SendFile[]: файл хранилища (SendFile::storage($file)) или локальный путь (SendFile::path(...), канал сам кладёт его в хранилище); вызывается и для предпросмотра;
    • getSendRecords() — записи CRM (лид, компания, клиент, сделка): их контакты предлагаются получателями, отправка попадает в их ленту;
    • createSendLink(SendContext $context) — личная ссылка этой отправки: в контексте канал (email, sms, позже telegram, max), получатель (email в нижнем регистре или телефон цифрами 79991234567), имя, запись CRM получателя и сотрудник; пакет сохраняет свою запись отправки с кодом и возвращает абсолютный URL; своё состояние можно положить в $context->ref;
    • afterSend(SendContext $context, CmsCommMessage $message) — тот же контекст и созданное сообщение (письмо или СМС, ещё не доставлено — его статус меняется позже): пакет запоминает сообщение в своей записи отправки, пишет свою запись ленты.
  • Одна отправка — один createSendLink() и один afterSend() в одной транзакции с сообщением: исключение в любом из них откатывает всё, и неудачная отправка не оставляет «висящих» ссылок. Если отправку отклонили проверки (адрес, номер, права), пакет не вызывается вовсе.
  • Открыть окно: MailUi::sendOnClick('proposal:12', $record) (onclick кнопки) или MailUi::sendUrl(...); $record — карточка, из которой открыто окно. Без окна (API, бот, ИИ): SendService::request('proposal:12', ['channel' => 'sms', 'recipient' => '+7…', 'text' => '…']) и SendService::send(); ошибки — send\SendException::$errors по полям.
  • Каналы — communication.channels (send\SendChannelInterface): канал сам решает вид (письмо — кнопка и вложения, СМС — текст и ссылка) и создаёт сообщение. Драйверы мессенджеров добавляют свои каналы, отправляемое не меняется.
  • В письме ссылка до отправки — заглушка https://sx-send.invalid/<тип>/<id>; при отправке она становится кнопкой с личной ссылкой, а если сотрудник её удалил — кнопка добавляется в конце.
  • Образец минимальной реализации — tests/support/SampleSendable.php (только для тестов и проверки на стенде, в рабочей конфигурации не регистрируется). Настоящая реализация — КП skeeks/cms-proposal (send\ProposalSendable).
  • Отказ пакета отправлять документ (пустой, истёк) — SendException(['sendable' => '…']) из createSendLink(): окно и форма письма показывают его, ничего не создаётся.

Проверки ссылок

services\LinkChecks::reason($sentAt, $ip, $userAgent, $signals) говорит, похоже ли открытие личной ссылки на проверку почтовой службой или защитной системой, которая запускает скрипты в настоящем браузере (проверка ссылок Outlook, Proofpoint, антиспам Яндекса и Mail.ru):

  • early — меньше 30 секунд после того, как письмо ушло (message_at отправленного сообщения);
  • network — сети почтовых служб и защитных систем (LinkChecks::NETWORKS, IPv4 и IPv6, плюс communication.linkCheckNetworks сайта). Публичных облаков в списке нет намеренно: клиенты сидят там через VPN;
  • browser — автоматический браузер (webdriver, нет языков, экран нулевого размера — признаки передаёт скрипт страницы) или известный User-Agent предпросмотра ссылок.

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

Общий код для страниц личных ссылок других пакетов: список роботов LinkChecks::ROBOT_PATTERNS / isRobot(), сигнал «страницу увидели» и признаки браузера — скрипт assets\LinkSignalAsset (link-signal.js, window.sxLinkSignal.whenSeen(delay, send), browserSignals(); страница со своей раскладкой печатает LinkSignalAsset::scriptUrl()), добавление меток к адресу — LinkMarks::addParams(). Страница КП (skeeks/cms-proposal) пользуется ими же.

Переходы по ссылкам из писем (шаг F)

Открытие письма не отслеживается (пиксель ненадёжен). Надёжный сигнал — переход по ссылке на наш сайт.

Метки при отправке. Каждое письмо CRM проходит через MailComposeService::queue() (личное, ответ, пересылка, «Отправить клиенту», кампания, черновик ИИ), и только там services\LinkMarks добавляет к ссылкам на наши домены недостающие utm_source=crm, utm_medium=email, utm_campaign (campaign-<id> у письма кампании, метка предложения — send\SendableMarksInterface, у КП и Презентаций proposal-<номер>, — иначе personal) и личный код sx=<код>:

  • размечается только текст, написанный сейчас, включая подпись, — до того, как добавлены цитата, пересылаемое письмо, личные ссылки предложений и ссылка «Отписаться»: старые письма сохраняют свои коды, ссылка с sx не меняется, повторная отправка берёт сохранённый текст;
  • метки менеджера (в любом регистре) остаются; чужие сайты, mailto:, tel:, якоря и личные страницы (/~kp/…, /~unsub/… — пути с /~) не трогаются; перенаправлений нет;
  • код — cms_comm_track: один на письмо и его первого получателя «Кому», с записью CRM получателя (лид, компания, клиент), кампанией и предложением; пишется в той же транзакции, только если что-то размечено;
  • в сохранённой копии письма (и в «Отправленных») — то, что ушло; в редакторе и черновике — исходные ссылки.

Учёт на сайте. Пакет сам подключает скрипт assets\VisitAsset (visit.js + link-signal.js) на сайте CRM — только на странице, открытой по ссылке с ?sx=, и пока у посетителя есть cookie визита sx_visit; остальные страницы, админка и AJAX ничего не грузят. Скрипт сразу убирает sx из адресной строки (метки utm_* для Метрики остаются; скопированный и пересланный адрес не записывается на получателя), засчитывает переход сигналом после показа страницы (POST /~comm-visit/seen, services\LinkVisits): роботы и проверки ссылок (LinkChecks) хранятся с причиной и не засчитываются, сотрудник, вошедший в CRM, — не переход. Засчитанный визит получает токен в cookie на время сеанса; следующие страницы — POST /~comm-visit/page в тот же визит. Визит заканчивается после 30 минут без страниц. Параметр sx добавляется в разрешённые параметры канонического адреса cms-seo, чтобы редирект его не срезал.

В CRM.

  • В ленте лида, компании или клиента одна запись на визит: «Перешёл на сайт из письма» — письмо (тема — если сотруднику доступен ящик), страницы визита по порядку, сколько страниц и минут. Проверка ссылок — один раз на письмо: «Ссылка из письма: возможно, проверка ссылок».
  • Уведомление отправителю письма о каждом новом визите (открывает карточку клиента).
  • Задача «Позвонить: клиент перешёл на сайт из письма» отправителю при первом засчитанном визите по письму — по настройке; не ставится, если у него уже есть открытая задача «Позвонить…» по этому клиенту (в том числе по открытому предложению). Для письма кампании решает её настройка «Задача менеджеру, когда клиент открыл предложение или перешёл на сайт».
  • В отчёте кампании — «Перешли на сайт» (visited_at получателя), фильтр и отметка у получателя.
  • ИИ-клиенты видят визиты в cms_crm_history_get (type: comm_visit, subtype: site_visit | link_check, details.pages); ссылки с кодом скрыты как личные (personalLinkPatterns), чтение не засчитывается.

Настройки — «Настройки почты» → «Переходы из писем» (components\LinkTrackingSettings): включение разметки, наши сайты (по домену в строке, поддомены входят; пусто — домены сайта; для skeeks.com — skeeks.com и skeeks-platform.ru), задача при переходе.

Сайт на другой установке (например, skeeks-platform.ru без CRM):

  1. Его домен — в списке «Наши сайты» CRM. Приём сигнала отвечает Access-Control-Allow-Origin только сайтам из списка, остальным — 403; cookie CRM не читаются и не ставятся, токен визита хранится в cookie самого сайта и передаётся в запросе. Сотрудник CRM там не распознаётся.
  2. На сайте разрешить sx в каноническом адресе (Yii::$app->seo->canUrl->ADDminor_pname('sx')): иначе cms-seo может ответить 301 без кода.
  3. Подключать <script src="https://<сайт CRM>/~comm-visit/visit.js"></script> в <head> только на страницах с sx в адресе и пока есть cookie sx_visit (так каждая страница грузит только своё).
  4. На сайте нужно уведомление о cookie.

Тесты: php tests/link-marks.php (без базы: какие ссылки размечаются, метки менеджера, HTML и текст письма, уже размеченные ссылки, роботы) и VISITS_TEST=local php tests/visits-stand.php (база стенда с откатом: письмо лиду 41 со ссылками в тексте и подписи, робот, проверка ссылок, сотрудник, визит со страницами, лента, уведомление, одна задача, конец визита, ответ с цитатой, история для ИИ без кода, получатель кампании, выключенная разметка).

Сообщение с сайта

services\SiteMessages::receive() — входящее сообщение, которое клиент написал на странице сайта (например «Задать вопрос» на странице КП), без почтового ящика: канал site, переписка по ключу страницы (site:proposal:12), связи с записями CRM — сообщение видно в их ленте как «Сообщение с сайта». Кто может писать (защита от спама, лимиты) и кого уведомить, решает вызывающий пакет; транзакция — его.

Боты мессенджеров: ядро для драйверов

Этап 5.1: общая часть ботов Telegram и MAX. Драйверы — отдельные пакеты (cms-communication-telegram, cms-communication-max), раздел «Чаты» и привязки — следующие шаги (см. ARCHITECTURE.md, «Мессенджеры и боты»).

  • Драйвер наследует bot\MessengerDriver (это ConnectionDriver): токен, секрет вебхука и прокси — зашифрованные секреты подключения; mode (webhook / poll), apiUrl, подпись менеджера. Драйвер только говорит на протоколе мессенджера: ключ события (updateKey() — повтор доставки даёт тот же ключ), разбор события в общий вид (parseUpdate() → bot\BotEvent), отправка одного сообщения (send(BotOutgoing) → id), скачивание файла, вебхук, опрос, лимиты текста и скорости. Отказ API (ничего не отправлено) — bot\BotException (blocked, retryAfter), любая другая ошибка — исход неизвестен. Регистрация: components.communication.drivers['telegram'] = TelegramDriver::class. describeBot() — строки карточки бота по последнему getMe() (Telegram: что бот видит в группах).
  • Прокси исходящих запросов — настройка бота «Прокси для запросов к API» (http://логин:пароль@узел:порт, socks5h://…), хранится зашифрованной целиком. Через неё драйвер шлёт все запросы к API и скачивает файлы; вебхук приходит на сайт напрямую. Пусто — напрямую.
  • Посредник для вебхука — настройка «Адрес приёма вебхука» (webhookBaseUrl, только HTTPS): мессенджеру ставится <адрес>/~comm-bot/<id> вместо адреса сайта (bot\Bots::webhookUrl()), ежечасная сверка подписки держит его же. Посредник пересылает этот путь на сайт без изменений, с заголовком секрета; сайт проверяет секрет как обычно. Нужен, когда мессенджер не достаёт до сайта (Telegram и серверы в России).
  • Бот — это учётная запись cms_comm_account своего канала (bot\Bots::account()): права сотрудников те же, что у ящика. Почта видит только ящики: CmsCommAccount::findMailboxes().
  • Приём: вебхук POST /~comm-bot/<id подключения> сверяет секрет из заголовка драйвера, одной вставкой пишет сырое событие в журнал cms_comm_bot_update (уникально по ключу события) и отвечает 200; задача comm.bot.apply разбирает события по порядку (bot\BotIngest): собеседник (cms_comm_identity), переписка чата (ключ <подключение>:<id чата>), участники групп, сообщение (<подключение>:<id чата>:<id сообщения>), правки и удаления, файлы (задача comm.bot.fetch-file, в хранилище CMS), состояние бота в чате; затем обработчики communication.botHandlers (bot\BotEventHandlerInterface). Ошибка — повтор до 5 раз, затем отметка в журнале. Режим опроса — задачи comm.bot.poll-all / comm.bot.poll, курсор в одной транзакции с событиями. comm.bot.maintain раз в час сверяет подписку вебхука и чистит журнал (30 дней). После установки — php yii cmsAgent/init.
  • История чата — BotIngest::applyHistory(): архивные сообщения без обработчиков и уведомлений, известные не меняются.
  • Отправка — bot\BotSend::queue($conversation, $text, [...]): сообщение и задача comm.bot.send в одной транзакции, первая строка — имя сотрудника; длинный текст частями, файлы следом, темп мессенджера, при «слишком много запросов» ждёт и досылает оставшиеся части, неизвестный исход не повторяется.
  • Стенд с боевыми ботами: communication.botReceive = false — ни вебхука, ни опроса (иначе события уйдут со стенда, а не с прода); communication.allowedChats — куда боту можно писать (<id чата> или <канал>:<id чата>; пусто при заданном allowedRecipients — никуда).

Тесты: php tests/bots.php (SQLite, поддельный мессенджер tests/support/FakeMessengerDriver.php), BOTS_STAND_TEST=local php tests/bots-stand.php (база стенда с откатом: вебхук через правила адресов, файл в хранилище, отправка).

Чаты и боты в админке

  • «Настройки → Боты мессенджеров» (AdminCmsCommBotController, только администратор): подключить бота — мессенджер, токен (хранится зашифрованным), как получать сообщения (вебхук или опрос), подпись в ответах. После сохранения бот проверяется в мессенджере (запоминаются его id и имя), получает учётную запись и подписку на сообщения; если этот же бот уже подключён, об этом сказано. Вкладки «Проверить бота» (бот, адрес вебхука, состояние подписки или ошибка), «Доступ сотрудников» (как у ящика: читать все чаты / только своих клиентов, писать в любые / только свои, уведомлять), «Журнал событий» (что пришло от мессенджера, что обработано, ошибки).
  • «Чаты» — пункт главного меню под «Почтой» (AdminCmsCommChatController): слева папки «Новые» (личные диалоги, которые никто не ведёт), «Мои», «Группы», «Все чаты», «Закрытые», при нескольких ботах — папка каждого; поиск по названиям и сообщениям; у чата — непрочитанные, «ждёт N мин», кто ведёт. Справа — чат: сообщения (клиент слева, мы справа), ответы на сообщения, файлы и картинки, «изменено», «удалено в мессенджере», статус отправки и причина отказа; «Взять диалог», «Передать…», «Закрыть» / «Открыть снова»; ответ с файлами (Enter — отправить, Shift+Enter — новая строка), первая строка — подпись сотрудника (в чате CRM её заменяет имя автора над сообщением). Новые сообщения и список обновляются сами, пока экран открыт. На телефоне список и чат сменяют друг друга.
  • Вид — как в Telegram, на цветах светлой и тёмной темы админки: экран занимает окно целиком (страница не прокручивается, только список и чат); сообщения одного автора подряд — серия (цветное имя над первым, фото у последнего, у наших сообщений тоже — фото сотрудника из CRM, у сообщений самого бота — значок бота); разделители дней «Сегодня», «Вчера», «23 октября»; время и статус внутри сообщения; цитата ответа с цветной полосой; файлы карточкой с типом и размером. Серии и дни расставляет chats.js по data-sx-author / data-sx-day сообщений, в том числе пришедших опросом. В шапке — название, число участников и управление диалогом; кнопка «Сведения» открывает панель с фото чата и участниками (роли «владелец», «админ», отметка «сотрудник»): на широком экране сбоку, на узком — поверх сообщений.
  • Папки — колонкой слева, как в мессенджерах (значок, подпись, число чатов с непрочитанным); на телефоне — строкой над списком.
  • Как в мессенджерах (1.18.0):
    • клик по цитате ведёт к исходному сообщению и подсвечивает его; более ранние сообщения догружаются, пока оно не найдётся; нет — подсказка;
    • меню сообщения (правая кнопка, долгое нажатие): «Ответить», «Копировать текст», «Создать задачу», «Ссылка на сообщение» (адрес «Чатов» с msg=ID — чат откроется на нём), сверху — ряд реакций, если мессенджер даёт боту их ставить;
    • реакции (chat\ChatReactions, таблица cms_comm_message_reaction): событие BotEvent::REACTION несёт весь новый набор реакций человека на сообщении и заменяет сохранённый; сотрудник ставит одну (та же ещё раз — снять), бот показывает в мессенджере последнюю реакцию сотрудника (MessengerDriver::reactionChoices() / react()). Под сообщением — emoji, число и кто поставил. В ленту клиента реакции не попадают (шум), в MCP — да (cms_comm_chat_get, reactions). Telegram: реакции приходят, только если бот — администратор группы (message_reaction), анонимные счётчики каналов не принимаются; MAX: событий и метода реакций в API нет;
    • кнопка «вниз» с числом новых сообщений, пока чат прокручен вверх; Esc в поле ответа отменяет цитату.
    • Подписка вебхука обновляется сама, если драйвер просит новые типы событий (MessengerDriver::webhookOutdated(), проверка раз в час).
  • Фото (bot\BotChats, задача comm.bot.chat-info для нового чата и раз в неделю): картинка группы, фото собеседника личного диалога, фото и роли участников группы — в хранилище CMS; без фото — инициалы.
  • Работа из чатов (этап 5.5):
    • «Это наш сотрудник» в «Сведениях»: участник группы связывается с сотрудником CRM (ChatIdentities::markWorker()), его сообщения — наши ответы; «×» снимает отметку;
    • «Создать задачу» у сообщения — стандартное окно задачи с ?source=message:ID: источник message (MessageRecordSource, MessageRecordFactory) понимает сообщения чатов — доступ по chat\ChatAccess, текст, автор и чат в описании, компания и лид из привязок чата, клиент из собеседника; сообщение группы, ставшее задачей, отдельно попадает в ленту компании; под сообщением — «Задача №N»;
    • команда в чате (chat\ChatTasks, обработчик botHandlers.tasks): /task текст, /task ответом на сообщение, упоминание бота со словом «задача». Ставят наши сотрудники в любом чате, остальные — только в чате, привязанном к компании. Задача — тому, кто ведёт чат, иначе менеджеру компании, иначе спросившему сотруднику; бот отвечает номером. Связь — cms_comm_chat_task; при смене статуса задачи (принята, выполнена, отменена) бот пишет в чат ответом на команду, если в «Сведениях» включено «Сообщать в чат о задачах» (task_notify). Слушатель смены статуса подключён в веб-приложении (админка, REST, MCP);
    • «Отправить клиенту» → «Мессенджер» (send\ChatSendChannel, канал chat): получатели — чаты, привязанные к записям клиента, и личные чаты его собеседников, куда сотруднику можно писать (SendContacts::CHAT); текст с подписью, личная ссылка кнопкой, файлы (PDF) — отдельными сообщениями. Сообщение обычное: видно в «Чатах» и в ленте записи.
    • Тест: CHAT_TASKS_STAND_TEST=local php tests/chat-tasks-stand.php.
  • Сервисы (одна операция — один сервис): chat\ChatAccess (кто видит чат и может писать — по строкам доступа к боту и привязкам чата к записям CRM), chat\ChatDialogs («Взять», «Передать», «Закрыть», «Открыть», прочитано), chat\ChatFolders (папки, поиск, непрочитанные), chat\ChatNotifier (уведомления о новых сообщениях — обработчик communication.botHandlers).
  • Уведомления: диалог, который кто-то ведёт, — только ему; личный диалог без ведущего — сотрудникам с «читать все» и «уведомлять» у бота; группы — только ведущему. Одно непрочитанное уведомление на чат: следующие сообщения обновляют его текст. Уведомление открывает чат в панели.

Тест: CHATS_STAND_TEST=local php tests/chats-stand.php (база стенда с откатом, два сотрудника: видимость, уведомления, папки, «Взять», «Передать», «Закрыть», возврат диалога, «ждёт ответа», прочитано, группы, право писать).

Чьи это чаты: привязки и лента

  • Чат → запись CRM (chat\ChatLinks): в чате строка «Клиент в CRM» — привязанные лид, компания, клиент, сделка (открывают карточку, «×» — отвязать), «Привязать…» (окно со стандартным поиском CRM по видимым сотруднику записям) и подсказки по названию чата «Компания / сайт.ru» (компания с этим сайтом в ссылках или с этим названием) — привязка одним нажатием. Привязка — cms_comm_conversation_link.
  • Лента (chat\ChatFeed): личный диалог — каждое сообщение в ленте привязанных записей (как письма, MessageLinker, тип thread), в том числе прежние при привязке; группа и канал — одна сводка за день (запись ленты chat_digest: сколько сообщений, кто писал, первые строки, «Открыть чат»), обновляется каждым сообщением дня; день — по часовому поясу CRM. Отвязка убирает и сообщения, и сводки.
  • Собеседник → клиент (chat\ChatIdentities): вручную («Привязать собеседника…» — лид, компания или клиент); по ссылке-приглашению — «Пригласить в бота» в карточке лида, компании, клиента даёт одноразовую ссылку ?start=<ключ> (32 знака, 30 дней, без личных данных): открывший её человек и его чат привязываются к записи, бот здоровается; по своему номеру — «Попросить номер» отправляет кнопку «Поделиться номером», подтверждённый мессенджером номер сохраняется у собеседника и привязывает его к единственному лиду, компании или клиенту с этим номером. Клиент хранится у собеседника, его личные чаты (и новые — с другими ботами сайта) получают ту же запись. Это делает chat\ChatIdentityHandler (обработчик событий бота, раньше уведомлений).
  • Блок «Мессенджеры» в карточках лида, компании и клиента: привязанные чаты, которые сотрудник видит (открываются в панели), «Пригласить в бота».
  • Уведомления: если диалог никто не ведёт — менеджерам привязанных записей (компании, клиента, исполнителю лида), которые видят чат; так и о группах.

Тест: CHAT_LINKS_STAND_TEST=local php tests/chat-links-stand.php (база стенда с откатом: лента диалога и сводка группы, отвязка, подсказка по сайту, приглашение, свой номер, новый чат привязанного человека, уведомление менеджеру компании).

Как это работает

  • Приём — фоновая задача comm.mail.poll на полосе communication, по расписанию cms-agent раз в 2 минуты (comm.mail.poll-all; после установки выполнить php yii cmsAgent/init). Забираются «Входящие» и «Отправленные», при первом приёме — последние 30 дней (communication.initialDays).

  • Письмо хранится один раз, даже если лежит в нескольких ящиках. Привязка к лиду (новому или в работе), компании и клиенту — по точному совпадению адреса; ответы с другого адреса привязываются по цепочке. Внутренняя переписка (все участники — наши адреса и адреса на доменах наших ящиков) не привязывается.

  • Отправка — задача comm.mail.send: SMTP ящика, затем копия в «Отправленные», если сервер не сохранил её сам. Письмо с неизвестным исходом не отправляется повторно.

  • Нужен обработчик очереди: php yii cms-job/worker --queue=communication (или общий диспетчер cms-job/worker).

  • communication.allowedRecipients ограничивает получателей (например ['@skeeks.com'] на стенде с копией боевой базы). communication.allowedPhones — то же для СМС (номера в любой записи); если allowedRecipients задан, а allowedPhones пуст, СМС не уходят никуда.

  • СМС «Отправить клиенту» — задача comm.sms.send (полоса communication): сообщение канала sms в переписке номера, запись провайдера (cms_sms_message) хранится в provider_ref. Повторно не отправляется.

  • Действия в ящике — задача comm.mail.apply подключения (та же блокировка, что у приёма); приём заодно сверяет ящик с CRM.

  • Импорт больше 200 строк — задача comm.import.leads (полоса communication, можно остановить; повторный запуск продолжает с несделанных строк).

  • Кампании рассылок — задачи comm.campaign.mailbox (письма ящика по его скорости) и comm.campaign.tick (раз в 5 минут, после установки выполнить php yii cmsAgent/init), полоса communication.

Пока не сделано: правила разбора входящих (автолиды, автоответ, стоп-список), сервис рассылок (этап 4C), списки получателей (компании и люди компаний массово из разделов CRM, сегменты по условиям).

Чтение для ИИ-клиентов (MCP/REST)

Если установлен skeeks/cms-mcp, пакет добавляет инструменты чтения почты и рассылок (src/mcp, scope cms.communication.read; регистрация через params['cmsMcp'] в src/config/common.php, без cms-mcp она ни на что не влияет):

  • cms_comm_message_list — письма, СМС и сообщения с сайта по клиенту, ящику, переписке, периоду, направлению, поиску; cms_comm_message_get — одно письмо; cms_comm_conversation_get — переписка целиком. Текст — собственный текст письма без цитат, подписи и HTML, длина ограничена, вложения только названиями.
  • cms_comm_account_list — ящики сотрудника без подключений и паролей; cms_comm_topic_list — темы рассылок.
  • cms_comm_mailing_check — «можно ли писать»: MailingPolicy::checkMany() для контактов клиента или списка адресов по теме, с причинами отказа; cms_comm_contact_state_get — стоп-лист, подписки и отписки контактов.

В общей истории клиента (cms_crm_history_get) письма описывает mcp\MessageHistoryDescriber (тот же доступ, что у ленты), отписки и согласия даёт mcp\MailingHistorySource, раздел mailing обзора клиента — mcp\MailingOverviewContributor. Видимость — CmsCommMessageQuery::forManager(), как в разделе «Почта». Адреса и телефоны замаскированы, пока не передан include_contacts=true; ссылка отписки /~unsub/ не выдаётся никогда. Письма с отметкой ИИ отдают prepared (OAuth-клиент, пользователь, время, edited).

Тест: COMM_MCP_TEST=local php tests/mcp-read.php на локальном сайте с cms-mcp (только чтение).

Чаты мессенджеров для ИИ-клиентов (MCP/REST)

mcp\ChatToolProvider — тонкие адаптеры над mcp\ChatToolService; права — как в разделе «Чаты» (chat\ChatAccess OAuth-сотрудника):

  • cms_comm_chat_list (cms.communication.read) — чаты по папке (new, mine, groups, all, closed, bot:<id>), поиску и клиенту (привязанные чаты и личные диалоги его людей): кто ведёт, сколько ждёт клиент, непрочитанные, привязки, собеседник;
  • cms_comm_chat_get (cms.communication.read) — чат, участники с ролями и отметкой «сотрудник», сообщения (автор, наше ли, текст, файлы названиями, ответ на, статус, задачи, отметка ИИ), can_send. Телефоны и адреса в текстах замаскированы без include_contacts=true;
  • cms_comm_chat_reply_check (cms.communication.write) — проверка ответа: доступ на отправку, бот в чате, allowedChats, длина, сообщение, на которое отвечают, выключатель и дневной лимит отправок через API (общие с письмами, «Настройки почты» → «Работа ИИ через API»); preview.text — точно то, что получит клиент (с подписью бота), и check_token;
  • cms_comm_chat_reply_send (cms.communication.send — право СМС и звонков, подготовка его не даёт) — тот же текст, check_token, новый idempotency_key и confirm=true после согласия человека. Ключи общие с письмами (cms_comm_api_send), повтор возвращает прежний результат, изменённый текст — changed_after_check. Сообщение отмечено «Подготовлено ИИ» (AiMark) и уходит обычной очередью бота;
  • cms_comm_chat_send_status (cms.communication.read) — состояние ответа (cms_comm_send_status по такому ключу отвечает так же);
  • cms_comm_chat_task_create (cms.task.write) — задача из сообщения, как «Создать задачу» в «Чатах», с припиской «Поставлено ИИ (клиент) по запросу …»; без confirm=true — только предпросмотр.

В истории клиента (cms_crm_history_get) сообщения чатов описывает MessageHistoryDescriber (chat_in/chat_out, ссылка на cms_comm_chat_get), дневные сводки групп — ChatDigestHistoryDescriber.

Тест: CHAT_MCP_TEST=local php tests/chat-mcp-stand.php (поддельный мессенджер, транзакция откатывается).

Действия ИИ-клиентов: личные письма с предложением (MCP/REST)

Инструменты действий (mcp\CommunicationActionToolProvider) — тонкие адаптеры над сервисами. Подготовка (черновики, кампании, темы) требует scope cms.communication.write, отправка проверенного черновика — отдельного cms.communication.mail_send, которого подготовка не даёт; статус отправки — cms.communication.read. Кампанию не запускает ни один инструмент. Права cms.communication.send (СМС и звонки cms-mcp) письма не касаются.

  • cms_comm_draft_create — черновики личных писем, режим по умолчанию. Черновик ложится в «Черновики» ответственного за клиента (его ящик по умолчанию; ответственный — Campaigns::responsibleOf()) или в указанный ящик cms_comm_account_id, если OAuth-сотрудник может с него отправлять. Черновик сохраняет MailDrafts, а проверяет форма письма владельца (ComposeForm::validate() — ящик, адреса, «только свои», тема, документ), как при отправке из «Почты». Предложение (offer: "proposal:<id>" — общая Презентация или КП этого клиента) кладётся как «Прикрепить из…», {Ссылка} в тексте — место кнопки; личная ссылка создаётся при отправке. Обязательно назначение purpose (см. ниже): у рекламного письма — тема рассылки cms_comm_topic_id, и оно уходит одному получателю с личной ссылкой «Отписаться» и List-Unsubscribe (MailDrafts::applyAiDraft() перед проверкой формы); личное и служебное — без темы. Без purpose, но с темой — рекламное. Файлы — attachments.
  • cms_comm_campaign_create — черновик кампании «Только добавленные получатели» (тема, отправитель, общая Презентация или КП каждому из шаблона, задачи при открытии и ответе). Нужно право «Рассылки».
  • cms_comm_campaign_recipient_add — получатели черновика кампании с личной темой и текстом (Campaigns::addRecipient(), by=ai). {Ссылка} и {Отписаться} добавляются в конец, если их нет. Запускает кампанию только сотрудник в карточке кампании.

Проверки по каждому клиенту (отказ — status=refused, reason, message, остальные строки выполняются): клиент виден OAuth-сотруднику (forManager()), у него есть email, адрес разрешён allowedRecipients, MailingPolicy по теме (отписан от темы или всех, жалоба, недоставка), письмо клиенту за recentDays (исходящие письма, привязанные к записи), это предложение клиенту за recentDays (send\SendHistoryInterface источника документа), черновик ИИ или кампания, уже ждущие этого клиента; КП другого клиента не прикладывается. Без confirm=true ничего не сохраняется — ответ показывает, что будет сделано и кому откажут (requires_confirmation).

Лимиты меняет в CRM сотрудник с правом управлять почтой: «Настройки» → «Настройки почты» → «Работа ИИ через API» (components\AiApiSettings, общий механизм настроек компонента сайта, без своей таблицы). Нужно право «Почта: управлять почтовыми ящиками и «Настройками почты»» (cmsCommunication/admin-cms-comm-settings, по умолчанию у администраторов); то же право открывает «Почтовые ящики» и весь раздел «Настройки почты», его можно выдать роли или сотруднику в правах CRM. Значение, заданное там, главнее; пустое поле — communication.aiLimits конфигурации сайта; нет и его — по умолчанию (perCall 20 строк за вызов, perDay 100 черновиков и получателей в день на сотрудника по часовому поясу CRM, sendPerDay 20 писем через API в день, recentDays 30, personalDays 90, serviceDays 365). 0 — значение: у perCall, perDay, sendPerDay ничего не разрешено, у recentDays проверки повтора нет; дни переписки и сотрудничества — от 1. Там же выключатель «Разрешить отправку писем через API»: выключен — cms_comm_draft_check и cms_comm_draft_send отказывают с причиной api_send_disabled и подсказкой, где включить, а черновики готовятся по-прежнему. Страница показывает, какое значение действует и откуда оно (AiActions::limitSources()). Дневной счёт — по отметкам ИИ: черновики, письма из них, личные письма получателей кампаний.

Отметка «Подготовлено ИИ» (services\AiMark, миграция m261029_100000): prepared_by=ai, prepared_client=mcp:<название OAuth-клиента>, prepared_user_id, prepared_at у черновика, письма и кампании; у получателя кампании — text_by=ai, text_client и text_edited_by. Ставит её только сервер по OAuth-токену текущего вызова (McpComponent::getCurrentClientName()); поля аргументов и формы её не задают и не снимают. Письмо из черновика ИИ получает отметку от своего черновика (только собственного) и prepared_edited, если сотрудник изменил тему или текст (prepared_hash); правка личного текста получателя сотрудником оставляет отметку и пишет text_edited_by. Отправку предложения отмечает запись ленты (data.ai с sent_by). Видно: в «Черновиках» (значок «ИИ»), в форме письма, на странице письма, в ленте карточки («Подготовлено ИИ … по запросу … · отправил …»), в карточке кампании и в колонке «Письмо» получателей, в отправках карточки предложения.

Журнал: каждый вызов пишет tool.start/tool.finish ApiLogService с oauth_client_id и пользователем, действие — запись ai.action (созданные id, причины отказов) в той же категории транспорта.

Тест: AI_ACTIONS_TEST=local php tests/ai-actions.php на локальном сайте с cms-mcp и cms-proposal (транзакция откатывается, письма только ставятся в очередь внутри неё).

Отправка проверенного черновика через API

Полный цикл: подготовка → проверка → согласие человека → отправка → статус. Отправляется только сохранённый черновик в «Черновиках» OAuth-сотрудника (создать его в своём ящике — cms_comm_draft_create с cms_comm_account_id), чтобы адресат и текст не менялись незаметно между проверкой и отправкой.

  1. cms_comm_draft_check {draft_id} (cms.communication.write) — services\AiSend::check(): черновик открывается так же, как в «Почте» (MailDrafts::fill(), applyAiDraft(), ComposeForm::validate() — ящик и право отправки с него, адреса, «только свои клиенты», MailingPolicy темы, документ) плюс правила API: клиент CRM привязан, один адрес из его карточки (Campaigns::recordAddresses()), без копий, есть тема, нет письма клиенту и отправки этого предложения за recentDays, предложение можно отправить (send\SendRefusalInterface: срок, страница), дневной лимит sendPerDay. Ничего не отправляет, личной ссылки и открытия не создаёт; предпросмотр — тема, текст и HTML с неработающей кнопкой (#offer-preview). Пройденная проверка возвращает check_token — отпечаток черновика, ящика, адресов, темы письма, текста, предложений, темы рассылки и клиента.
  2. cms_comm_draft_send {draft_id, check_token, idempotency_key, confirm:true} (cms.communication.mail_send): отпечаток считается заново — любое изменение черновика после проверки даёт changed_after_check. Письмо ставит в очередь MailComposeService::queue() (личная ссылка, «Отписаться», List-Unsubscribe, лента, привязки, задача отправки) в одной транзакции со строкой cms_comm_api_send ключа (миграция m261101_100000), затем черновик удаляется. Повтор с тем же ключом и тем же draft_id + check_token возвращает прежний результат (repeated: true), с другими — idempotency_key_reused; отказ ключ не занимает. Параллельный повтор ждёт уникального индекса и получает результат первого.
  3. cms_comm_send_status {idempotency_key | cms_comm_message_id} (cms.communication.read): state — queued, sending, sent (принято почтовым сервером отправителя, не доставка), failed с ошибкой, unknown (повторно не отправляется); delivery — not_confirmed или bounced (в переписке пришло автоматическое письмо о недоставке, или адрес отмечен недоставляемым после отправки); offers — send_id, честное открытие (opened, views_count) и отдельно link_checks_count проверок ссылок почтовыми службами (CampaignSourceInterface::states()); replies — ответы людей в переписке. Чтение статуса ничего не открывает. Неизвестный ключ — not_found: письмо не отправлялось.

Письмо, отправленное через API, отмечено «отправлено ИИ через API (клиент)»: AiMark::of() находит строку cms_comm_api_send; запись ленты об отправке предложения хранит data.ai.sent_via.

Темы рассылок через API — cms_comm_topic_create, cms_comm_topic_update (services\MailingTopics, cms.communication.write, только администратор, как раздел «Темы рассылок»; название уникально, правка частичная). Отправка тему никогда не создаёт; пустой cms_comm_topic_list возвращает setup_required.

Тест: AI_SEND_TEST=local php tests/ai-send.php (темы, Презентации, проверка, отправка, ключ, изменение после проверки, состояния и недоставка, права, чистота статистики; транзакция откатывается).

Назначение письма, файлы и тестовый режим

Назначение (services\LetterPurpose, колонки cms_comm_draft.purpose и cms_comm_api_send.purpose, миграция m261102_100000) задаёт сам запрос purpose, сервер проверяет его основание; ни отдельный текст, ни ручная отправка, ни приложенная Презентация назначение не меняют.

purpose Основание (проверяет сервер) Тема и «Отписаться»
advertising действующая тема рассылки, MailingPolicy темы, нет письма клиенту и этого предложения за recentDays обязательны, List-Unsubscribe
personal клиент сам писал: входящее письмо человека (не автоответ), привязанное к записи, её компании или лидам компании, за personalDays (90), или ответ на такое письмо нет; тема запрещена
service сотрудничество: действующая сделка компании/клиента или оплаченный счёт за serviceDays (365) нет; тема запрещена
individual_offer всё, что у advertising (тема, MailingPolicy: стоп-лист, отказ от темы, жалоба, недоставка; повтор письма и предложения), плюс обязательное предложение offer (Презентация или КП) тема обязательна, но только для проверки отказов: строки «Отписаться» и List-Unsubscribe нет

Индивидуальное предложение — первое предложение конкретному человеку: своя тема и текст, Презентация или КП с личной ссылкой, скриншот. Выбирается только явно (purpose: "individual_offer"): один получатель, имя или приложенная Презентация его не включают, старые черновики не переводятся. Тему письмо хранит (cms_comm_message.cms_comm_topic_id) без unsubscribe_url; решает ComposeForm::withOptOut() — у кампании письмо всегда со строкой отписки, individual_offer в кампании — ошибка формы, а cms_comm_campaign_create и cms_comm_campaign_recipient_add его отклоняют. {Отписаться} в тексте такого письма убирается. Проверка показывает unsubscribe: {line, headers} и роль темы; признак отписки входит в check_token. Отправка — та же: cms_comm_draft_check → согласие человека → cms_comm_draft_send по одному письму.

Подпись — подпись сотрудника для ящика из «Мои настройки почты» (cms_comm_signature, services\MailSignatures): черновик ИИ получает её под текстом один раз, тем же блоком «-- », что ставит форма письма; картинки без места {Картинка} встают над ней. Подпись — часть текста, поэтому предпросмотр проверки совпадает с письмом, а изменение подписи меняет check_token. cms_comm_account_list отдаёт signature_html, cms_comm_draft_get и cms_comm_draft_check — signature {html, text, in_letter}; нет подписи — предупреждение, ИИ подпись не придумывает.

Жалоба и недоставка запрещают письмо любого назначения; отписка от всех у личного и служебного письма — предупреждение, настройки контакта письмо не меняет. Те же правила проверяет форма письма в «Почте» (ComposeForm::$purpose, его ставит MailDrafts::applyAiDraft() из черновика ИИ), поэтому интерфейс и API отказывают одинаково. Назначение входит в check_token, показывается в проверке (purpose.code, name, opt_out) и пишется в журнал отправки.

Файлы (services\AiDraftFiles, cms_comm_draft_file): файл сначала загружается cms_storage_file_upload (cms-mcp), затем указывается id — в items[].attachments черновика или cms_comm_draft_attachment_add {draft_id, cms_storage_file_id, inline}; убрать — cms_comm_draft_attachment_remove {draft_id, file_id}; посмотреть — cms_comm_draft_get, правка темы, текста, адреса, назначения — cms_comm_draft_update. Берутся только файлы этого сайта, загруженные OAuth-сотрудником; тип — по содержимому (finfo), не по имени: PNG, JPEG, GIF, WebP, PDF; до 10 файлов, 10 МБ на файл, 20 МБ всего. В письмо идёт копия в хранилище почты (MailFiles::storeReceived()), поэтому снятие файла с письма не трогает загрузку сотрудника, а тот же файл повторно не добавляется. inline: true — картинка в тексте: встаёт на место {Картинка} (иначе в конец) и уходит частью письма с Content-ID (cid:), вложение — частью attachment; ссылок на файлы письмо не содержит. Проверка возвращает attachments (id, имя, MIME, размер, inline, отпечаток, count, total_size, limits); файлы входят в check_token. Отправленное письмо показывает их в cms_comm_send_status (message.attachments), имена — в cms_comm_message_get.

Тестовый режим (test_mode: true в cms_comm_draft_create, cms_comm_draft_check, cms_comm_draft_send): только администратору и только паре «запись CRM — точный адрес» из communication.aiTestRecipients конфига сайта (['company:712' => ['test@example.com']]; ни домена, ни всех людей компании). Инструменты список не меняют. Снимает ровно две проверки — «недавно было письмо» и «это предложение уже отправлялось» (recent_letter, recent_offer); остаются права, адрес, назначение, файлы и их размеры, confirm, check_token, ключ повтора, дневные лимиты, жалобы, недоставка, отписка, неизвестный результат. Режим входит в check_token, показан в проверке (test_mode) и в журнале (cms_comm_api_send.test_mode); force нет.

Тест: AI_LETTERS_TEST=local php tests/ai-letters.php (загрузка PNG, отказ SVG с именем .png, картинка и вложение в MIME, повторная тестовая отправка, отказ реальному клиенту и чужому адресу, ключ, изменение после проверки, назначения и интерфейс почты; транзакция откатывается). AI_OFFER_TEST=local php tests/ai-offer.php — индивидуальное предложение (нет строки и заголовков отписки, скриншот и личная ссылка Презентации в MIME, стоп-лист, жалоба, недоставка и отказ от темы блокируют, смена назначения после проверки, ключ повтора, кампании, advertising с отпиской, personal и service по-прежнему) и подпись ящика.