Mail 26.1100.0: smtpd.php без токена отвечает 404, а ящики удалённых пользователей удаляет агент
Mail 26.1100.0 вышел следом за 26.1000.0. Вендор пишет только о повышенной стабильности подключённых ящиков. В коде за этим стоят три вещи: инфраструктура «поколений источника» для переезда ящика на другой IMAP-сервер без потери истории (по умолчанию выключена), переделанное удаление ящиков и писем и защита встроенного SMTP-сервера от запуска по HTTP и от второго экземпляра.
Проверить свой код стоит тем, кто запускает встроенный SMTP-сервер, программно удаляет письма или ящики или наследует классы IMAP-синхронизации модуля.
Что сломается
Проверьте, как вы запускаете smtpd.php
HTTP-запрос к smtpd.php без переменной окружения BITRIX_SMTPD_COMMAND_TOKEN теперь получает 404. Раньше файл запускал CSMTPServer::Run() при любом вызове. Страница admin/mail_smtpd_manager.php передаёт токен процессу через окружение сама, а запуск из CLI без токена работает: Run(false) сам записывает команду start. Если же сервер поднимал крон или скрипт запросом по URL, после обновления этот запрос получит 404, и сервер не стартует. Найти такие места:
crontab -l 2>/dev/null | grep -n smtpd
grep -rn "smtpd.php" /etc/cron* /etc/systemd/system 2>/dev/null
Второй экземпляр запустить больше нельзя. Сервер берёт flock на <upload_dir>/.smtpd.lock и проверяет, что это обычный файл с теми же dev и ino. Сторож в кроне, который перезапускал сервер на всякий случай, лишнего процесса уже не поднимет.
Команды start и stop теперь хранятся в b_option (smtpd_command, smtpd_stop_token) и читаются с мастера (StartUsingMasterOnly). Раньше стоп передавался флагом кеша smtpd_stop. Если ваш код останавливал сервер через этот флаг, теперь сервер на него не реагирует.
Поменялись и сигнатуры. CSMTPServer::Run($commandToken = false) завершается exit, Start() возвращает bool, добавлены SetCommand(), GetCommand(), ReportStartupError() и CSMTPServerHost::Shutdown().
Не ждите, что ящики пользователя исчезнут вместе с ним
CMail::onUserDelete() больше не удаляет ящики удалённого пользователя сразу. MailboxDeletionRequestService::request() деактивирует ящик, ставит агент Bitrix\Mail\Helper::deleteMailboxAgent(id) с интервалом 60 секунд, чистит счётчик и данные синхронизации. Тот же сервис использует MailboxConnector.
Если ваш обработчик удаления пользователя или отчёт по ящикам рассчитывает, что ящиков уже нет, какое-то время он будет видеть их деактивированными. Фильтруйте такие выборки по ACTIVE.
CMailbox::Delete() тоже переделан:
- сначала снимает агенты синхронизации ящика и ставит
SYNC_LOCK; - до 30 секунд ждёт блокировку операций миграции (
MailboxDeletion::takeMailboxOver()); - удаляет физические данные всех поколений до обхода писем и ещё раз после него.
Проверьте код вокруг CMailMessage::Delete()
Сигнатура теперь CMailMessage::Delete($id, ?int $mailboxId = null, bool $entityOptionsClearedByCaller = false, bool $withPlacements = true). С параметрами по умолчанию метод удаляет и строки b_mail_message_uid этого письма во всех поколениях, очереди выгрузки и удаления, отпечатки и журнал сопоставления (Helper::deleteMessagePlacements()). В 26.1000.0 Delete() этих строк не трогал.
Если после Delete() ваш код сам чистил b_mail_message_uid или, наоборот, рассчитывал найти там строку письма, поправьте его. Новый параметр стоит последним намеренно: по docblock, все позиционные вызовы передают вторым аргументом ящик.
Учтите вторую копию пароля у новых IMAP-ящиков
CMailBox::Add для IMAP-ящика сразу создаёт первое поколение (G1) в b_mail_mailbox_source_generation с копией SERVER, PORT, USE_TLS, LOGIN и PASSWORD, а затем выставляет b_mail_mailbox.ACTIVE_GENERATION_ID (BackfillService::onMailboxAdded()). Флаг source_generations_enabled на это не влияет, он решает только, работают ли поколения при синхронизации. Выходит, что у каждого нового IMAP-ящика реквизиты с паролем лежат в двух таблицах. Настройку шифрования пароля поколения MailboxSourceGenerationTable::passwordCryptoEnabled() берёт у b_mail_mailbox: шифрование в ORM включается по имени таблицы, и без этого пароль поколения молча остался бы на старом шифровании модуля.
Добавьте GenerationScope в переопределения
Необязательный параметр ?GenerationScope появился:
- в статических методах
MailboxDirectory:fetchAll*,fetchOne*(кромеfetchOneById()),fetchTrashAndSpamHash(),countSyncDirs(),countMessagesSyncDirs(),getMinSyncTime(),resetDirsTypes(),updateSyncDirs(); - в
MailMessageUidTable::getFirstLocalUID(),getLastLocalUID()иgetLocalUID(); - в конструкторах
MailboxDirectoryStorageиMailboxDirectoryHelper; - в
Helper::getLastDeletedOldMessageInternaldate(),SettingsReader::read(),SyncFlagsUpdater::update()иTypesAssigner::assign()(SettingsReader,SyncFlagsUpdaterиTypesAssignerобъявленыfinal, наследовать их нельзя).
Другие необязательные параметры получили MailboxDirectory::invalidateCache(?int $mailboxId = null, ?int $generationId = null), Helper\Mailbox::listMessages(..., bool $withGenerationScope = true), MailboxDirectoryHelper::syncChildren($parent, ?Mailbox $engine = null), Mail\Imap::getUidsSince(..., ?int $minimumUid = null) и MailsFoldersManager::syncMovedMessages(..., $generationId = 0).
Вызывающий код все эти изменения переживёт. Наследникам придётся поправить сигнатуры. PHP 8 не пропустит переопределение без нового параметра, и класс упадёт с fatal error о несовместимой сигнатуре прямо при загрузке. Конструкторы PHP так не проверяет, там сверьте, что вы передаёте родителю. Исправленное переопределение выглядит так:
<?php declare(strict_types=1);
namespace Vendor\Mailext;
use Bitrix\Mail\Internal\Service\SourceGeneration\GenerationScope;
use Bitrix\Mail\MailboxDirectory;
class AuditedMailboxDirectory extends MailboxDirectory
{
// до 26.1100.0 было fetchAllSyncDirs($mailboxId)
public static function fetchAllSyncDirs($mailboxId, ?GenerationScope $scope = null)
{
$dirs = parent::fetchAllSyncDirs($mailboxId, $scope);
// своя логика поверх списка папок
return $dirs;
}
}
У Helper\Mailbox\Imap появилось protected-поле $resyncWalkIncomplete = true, а resyncDirInternal() может вернуть 'incompleteList'. Если обход папки шёл по неполному списку, письма в «переезжающем» состоянии больше не объявляются удалёнными на сервере. Комментарий в lib/helper/mailbox/imap.php объясняет, почему признак передаётся полем: новый параметр сломал бы объявление resyncMessages() у наследника. Из-за осторожного значения по умолчанию свой обход папки, который вызывает resyncMessages() и не выставляет $resyncWalkIncomplete = false, по своему списку ничего не удалит.
Controller\Message (lib/controller/message.php) теперь ищет строку UID только в активном поколении ящика. ID строки из архивного поколения больше ни на что не разрешается.
Новое
Переезд ящика на другой сервер лежит за флагом
Около 40 классов в lib/Internal/Service/SourceGeneration/* готовят переезд ящика на другой IMAP-сервер без потери истории. Включает их опция source_generations_enabled, по умолчанию N (у модуля появился default_option.php). Опция source_generations_migration_stopped работает аварийным тормозом: останавливает новые миграции и синхронизацию подготовленных поколений.
MigrationService::run()за один ограниченный проход двигает операцию по стадиямMigrationStage:INITIALIZING_G1,G1_READY,PREPARING_G2,SHADOW_MATCHING,MATCHING,TAIL_APPEND,AWAITING_SWITCH_AUTHORIZATION,READY_TO_SWITCH,SWITCHED. Если папка меняет UIDVALIDITY, срабатывает откатRESETTING_IMPORT.- Письма сопоставляют
Matching\MessageMatcher,FingerprintBuilderиCanonicalMessageNormalizer. В базе хранятся только SHA-256 отпечатки Message-ID, содержимого и ссылки мигратора. - Письма, которые внешний сервис не перенёс, модуль дописывает в новый ящик сам через IMAP APPEND (
TailAppendService,TailMessageBuilder). Если сервер не вернул APPENDUID, письмо находят по одноразовой метке в заголовкеX-Bitrix-Mail-Tail-Mark(TailMarkService). - Переключает ящик
MailboxMigrationSwitcher: меняет указатель поколения, пишет проекцию IMAP и SMTP вb_mail_mailboxи отправляет старый адрес в историю через новыйAddressHistory::registerWithinTransaction(). GenerationScopeфильтрует физические строки поGENERATION_ID. Строки с неявным0считаются строками активного поколения.
Для внешнего мигратора модуль оставил контракты в lib/integration/MailService/*: ReadyMailboxReceiver::accept(ReadyMailbox), MailboxSwitchAuthorizationReceiver::accept(), MailboxMigrationCancelReceiver::accept() и MigrationStatusProvider. Методы accept() приёмников внутри модуля никто не вызывает, а MigrationStatusProvider модуль использует сам: через него статус читают MigrationActionGuard, Controller\Migration и агент миграций. Свой идентификатор письма мигратор может передать в заголовке X-Bitrix-Mail-Migrator-Reference (Imap::MIGRATOR_REFERENCE_HEADER).
Статус переезда отдаёт Controller\Migration: getStatusAction(int $mailboxId) и getStatusesAction(array $mailboxIds), только POST с CSRF и не больше 100 ящиков в пакете. На фронте его читает расширение mail.migration-state.
Пока миграция ящика видна как активная, MigrationActionGuard блокирует действия с ним в ajax.php клиента, MailboxConnecting, MessageActions и MessageSender. REST mail.message.* в это время отвечает ошибкой MAILBOX_MIGRATION_ACTION_BLOCKED. Если ваша интеграция шлёт письма через REST, научите её обрабатывать этот код. Если статус прочитать не удалось, REST отвечает MAILBOX_MIGRATION_STATUS_UNAVAILABLE, его тоже стоит обработать. isBlocked() в интерфейсе в этом случае возвращает true.
Фоновая часть:
- агент
Internal\Agent\MigrationOperationAgent::run()раз в 60 секунд, пакет 20, бюджет 60 секунд; - агент
DeletedMailboxCleanupAgent::run(0)раз в час, курсор по ID ящиков хранится в имени агента; - очередь Messenger
mail_migration_notificationи уведомлениеIntegration\Im\Notification::notifyUserAboutMailboxMigration(); - степпер
Update\SourceGenerationBackfillStepper, который раздаёт G1 существующим ящикам. Он выключен опциейsource_generations_backfill_enabledи без неё завершается на первом же шаге.
Ссылки на большие вложения выбирают шлюз
Появился интерфейс Integration\Disk\MailAttachmentLinkGateway с двумя реализациями. DiskMailAttachmentLinkGateway вызывает команды диска CanCreateMailAttachmentLinkCommand и CreateMailAttachmentLinkCommand. LegacyMailAttachmentLinkGateway создаёт обычную ручную ссылку, это временный класс, чтобы mail мог выйти раньше disk 26.1200.0. LargeAttachmentStorageFactory выбирает шлюз через class_exists() при каждом вызове, рядом появился DTO Dto\LargeAttachmentTokenPayload. Как большие вложения уходят на Диск, мы разбирали в mail 26.900.0.
Массовое подключение фильтрует сотрудников по правам
Провайдер entity-selector mail-massconnect-user (Integration\UI\EntitySelector\MassConnectUserProvider) подменяет user. Клиентские опции он игнорирует и подставляет свой набор SAFE_OPTIONS, а выбор ограничивает сотрудниками отделов, на которые у текущего пользователя есть право редактирования ящиков (MAIL_MAILBOX_LIST_ITEM_EDIT). В форме массового подключения теперь можно выбирать отдельных сотрудников.
Прочее
Helper\Message\MessageSender::__construct() принимает зависимости: интерфейс MessageSenderSourceProvider (по умолчанию DefaultMessageSenderSourceProvider) и замыкания транспорта, цитаты, санитайзера и резолвера хоста.
Кроме того, появились CAllMailMessage::completeClosureChain(), Helper\Mailbox::getLastSyncRefusal(), isSyncAllowedByLicense() и syncUnderHeldMailboxLock(), Integration\Attachment::materialize*() и hasAllExpectedAttachments(), Internal\Service\Attachment\FileNameNormalizer и ActiveMessageLocator.
БД
В install/index.php в InstallDB() сразу после RegisterModule("mail") вызывается MigrationOperationSchema::clearCache(). Он сбрасывает закешированный ответ на вопрос, готова ли схема b_mail_migration_operation. Отрицательный ответ хранится 300 секунд, положительный сутки, плюс WeakMap на соединение. InstallDB() работает при установке модуля, а как кеш сбрасывается при обновлении, из кода релиза не видно.
Саму схему меняет install/migrations/tables.php:
b_mail_mailbox:ACTIVE_GENERATION_ID int NOT NULL DEFAULT 0и индексIX_B_MAIL_MAILBOX_ACTIVE_GEN;- колонка
GENERATION_ID int NOT NULL DEFAULT 0вb_mail_message_uid,b_mail_message_upload_queue,b_mail_message_delete_queueиb_mail_mailbox_dirс составными индексами, среди них покрывающийIX_MAIL_MESSAGE_UID_GEN_LISTдля списка, страницы папки и счётчиков; b_mail_message: индексIX_MAIL_MESSAGE_MBX_ID (MAILBOX_ID, ID)для keyset-обхода;- индекс
IX_MAIL_MSG_UID_DIR_START_DATEиз 26.1000.0 переименован вIX_MAIL_MESSAGE_UID_DIR_START_DATE; b_mail_message_delete_queue.PKпотерялunsigned.
Новые таблицы:
b_mail_mailbox_source_generation: поколения с копией IMAP- и SMTP-реквизитов, статусыPREPARING,ACTIVE,ARCHIVED;b_mail_migration_operation: очередь и состояние операций;b_mail_message_fingerprint;b_mail_source_generation_match: журнал решенийMATCHED,NEW,AMBIGUOUS;b_mail_source_generation_tail_mark: хранит толькоMARK_HASH, UNIQUE-ключи по хешу и по паре(GENERATION_ID, MESSAGE_ID).
В install/migrations/agents.php добавлен MigrationOperationAgent::run(). DeletedMailboxCleanupAgent добавляется, только если агента нет по маске имени ...::run(%. Новые опции: source_generations_enabled, source_generations_migration_stopped, source_generations_backfill_enabled, smtpd_command, smtpd_stop_token.
Мелочи и находки
- Docblock степпера бэкфилла объясняет, почему тот выключен.
bindClass()даёт агенту интервал в одну секунду, и портал получал непрерывный поток пакетных апдейтов. Степпер, которого не пустили, завершается сам и заодно убирает свой агент и опцию. Там же ссылка на внутреннюю базу знаний.ai/knowledge-base/notes/source-generation-indexes.mdи задачу «P1.T3», а вHelper\Mailboxпопадаются метки внутренних документовTPL-01,ALG-01,DATA-01. TailMarkServiceиTailMessageBuilderпрямо описывают слабость заголовка отправкиX-Bitrix-Mail-Message-UID. В нём открытый и угадываемый ID строки, его читает любой проход синхронизации в любой папке, и обработка переписывает названную строку. Поэтому в восстановленных письмах он запрещён (FORBIDDEN_HEADERS). Остаточный риск тоже записан. Тот, у кого есть доступ к ящику, может подложить письмо с той же меткой, но только пока метка жива, а живёт она три дня.- Комментарий к
TailMarkService::HEADERгласит «Not an X-Bitrix-Mail-* name on purpose», а значение константы при этомX-Bitrix-Mail-Tail-Mark. TailMessageBuilderпринудительно кодирует части в base64. Ядро берёт кодировку из опций портала, по умолчанию 8bit, а IMAP APPEND отправляет литерал, не спрашивая сервер, примет ли он 8-битные байты.GenerationScope::forMailbox()читает указатель поколения сырым SQL, потому что ORM-карта другого релиза может не знать колонку. ВBackfillService::onMailboxDeleted()та же мысль записана короче: файлы обновления приезжают на установку раньше, чем её база.Helper::deleteMessagePlacements()собирает ID отдельным запросом вместо подзапроса. Колонка строковая, а сравнение строки с числом, по комментарию, на одной СУБД работает, а на другой даёт ошибку.MailboxDirectorySettingsService::getSettings()иloadChildren()получилиbool $mayEditMailbox. Контроллер его передаёт, но внутри параметр пока не используется.Controller\Migrationдобавляет фильтрIntranet\ActionFilter\IntranetUserбезLoader::includeModule('intranet').Controller\DraftиController\MailTemplateдобавляют тот же фильтр только после такой проверки.