📧 Mail — Отправка писем в PageBlocks
Класс Mail предназначен для формирования и отправки писем с использованием стандартного сервиса modMail в MODX. В PageBlocks он упрощает работу с почтой и поддерживает Fenom-шаблоны для генерации содержимого.
📌 Назначение
Mail оборачивает работу с modMail и позволяет настраивать все параметры письма через цепочку методов. Тело письма можно задавать напрямую или генерировать через Fenom-шаблон.
⚙️ Дефолтные значения
При создании объекта Mail автоматически подтягиваются некоторые параметры из системных настроек MODX:
| Свойство | Значение по умолчанию |
|---|---|
from | emailsender из системных настроек MODX |
fromName | site_name из системных настроек MODX |
sender | emailsender из системных настроек MODX |
isHtml | true — письма отправляются в формате HTML |
Пример:
$mail = Mail::to('user@example.com');
// $mail->from будет равен $modx->getOption('emailsender')✅ Быстрый пример
use Boshnik\PageBlocks\Support\Mail;
Mail::to('recipient@example.com')
->subject('Добро пожаловать')
->message('<p>Спасибо за регистрацию!</p>')
->send();🗂️ Методы класса
➡️ Mail::to(string $email, string $name = ''): Mail
Указывает адрес получателя и, при необходимости, его имя.
➡️ subject(string $text): Mail
Устанавливает тему письма.
➡️ message(string $html): Mail
Задаёт тело письма напрямую (HTML или текст).
➡️ view(string $tpl, array $data = []): Mail
Рендерит письмо через Fenom-шаблон и переданные данные.
Пример:
Mail::to('user@example.com')
->subject('Ваш заказ')
->view('file:emails/order', ['order' => $order])
->send();➡️ attach($file, string $as = ''): Mail
Прикрепляет файл к письму. Принимает либо строку с путём, либо массив, где путь лежит в ключе url или path, плюс необязательное name — имя, которое увидит получатель. Со строкой имя для получателя задаёт второй аргумент; без него берётся имя самого файла.
->attach(MODX_BASE_PATH . 'assets/docs/contract.pdf');
->attach([
'url' => MODX_BASE_PATH . 'assets/docs/contract.pdf',
'name' => 'Договор.pdf'
]);Файл, который к моменту отправки пропал, письмо не отменяет: оно уйдёт без вложения, а факт попадёт в лог.
➡️ replyTo(string $email): Mail
Устанавливает адрес для ответа.
➡️ cc(string $email): Mail
Добавляет адресата в копию.
➡️ bcc(string $email): Mail
Добавляет скрытую копию.
➡️ from(string $email, string $name = ''): Mail
Изменяет отправителя и имя.
➡️ sender(string $email): Mail
Изменяет MAIL_SENDER.
➡️ setHTML(bool $html = true): Mail
Указывает, в каком формате отправлять письмо (HTML или plain text).
➡️ header(string $name, string $value): Mail
Добавляет свой заголовок — например, List-Unsubscribe в рассылке: почтовые службы понижают репутацию пачке писем, которая уходит без него.
➡️ send(): bool
Отправляет письмо сейчас и возвращает, ушло ли оно в этот момент. Адрес, тему и тело берёт из цепочки — аргументов у метода нет.
Неудачный send() не теряется: письмо остаётся в очереди, и попытка повторится. См. раздел «Повтор при сбое».
➡️ queue(?DateTimeInterface $at = null): bool
Записывает письмо и возвращает управление, не трогая SMTP. См. раздел «Очередь».
➡️ context(string $key): Mail
Помечает письмо для журнала. См. раздел «context — метка, а не украшение».
🗂️ Пример: Fenom + вложение + CC/BCC
Mail::to('user@example.com')
->subject('Ваш заказ принят')
->view('emails/order', ['order' => $order])
->attach([
'url' => MODX_BASE_PATH . 'assets/invoices/invoice.pdf',
'name' => 'Счет-фактура.pdf'
])
->cc('manager@example.com')
->bcc('admin@example.com')
->send();📝 Логирование ошибок
Если письмо не удалось отправить, в системный лог MODX (core/cache/logs/error.log) будет записана ошибка с уровнем ERROR и префиксом [Mail].
Журнал
Каждое письмо попадает в pb_mails до того, как будет отдано SMTP. В записи видно, когда письмо собрали, сколько раз пытались отправить, что ответил сервер и ушло ли оно в итоге.
Это не удобство. Пока журнала не было, от отправки оставался ровно один след — строка в логе MODX, и только при неудаче. Вопрос «клиент говорит, что ключ так и не пришёл» так не закрывался: лог ротируется, успешная отправка в него не пишется вовсе, а «ушло, проверьте спам» и «не ушло» выглядели одинаково — как ничего.
Тело письма тоже сохраняется: без него нечего повторять.
Очередь
Mail::to($email)->subject($subject)->message($html)->context('app.register')->queue();
// Отложить — например, напоминание о брошенной корзине.
Mail::to($email)->subject($subject)->message($html)->queue(now()->addHours(3));Везде, где письма ждёт живой человек, правильный выбор — queue(): недоступный почтовый сервер превращает синхронную отправку в страницу, которая висит до TCP-таймаута и затем отдаёт ошибку, причём письмо теряется в любом случае.
Если таблицы журнала нет, queue() отправит письмо сразу
Отложенная отправка — оптимизация, а не обязательное условие: письмо не должно исчезать из-за того, что миграция не применена.
Повтор при сбое
Просить о повторах не нужно: неудачный send() остаётся в очереди. Вызывающему коду не обязательно знать, что очередь вообще существует.
Паузы идут так: минута, пять, пятнадцать, час, шесть часов. Пять попыток укладываются примерно в восемь часов.
Начало этой лестницы короткое намеренно. Большинство сбоев — занятый или недоступный на минуту почтовый сервер, и они проходят сами; к четвёртой неудаче причина уже не заминка, а конфигурация или блок-лист, и частые попытки скорее портят репутацию отправителя, чем повышают шансы.
Состояния записи
| Состояние | Что означает |
|---|---|
queued | ждёт обработчика: либо ещё не пытались, либо выжидает паузу |
sending | прямо сейчас взято обработчиком |
sent | ушло |
failed | попытки исчерпаны |
skipped | задержано режимом разработки |
После неудачи запись возвращается в queued, а не остаётся в failed: состояние должно говорить, что будет дальше, а дальше будет ещё одна попытка. failed означает «само больше ничего не произойдёт».
context — метка, а не украшение
Произвольная строка: shop.invoice_copy, app.register, repository.license. Журнал фильтруется по ней. Без метки на вопрос «ушли ли вчерашние счета» отвечают, читая темы писем одну за другой; с меткой — одним фильтром. Стоит задавать всюду, где письма уходят пачкой.
Разбор очереди: cron
Без cron очередь двигается только тогда, когда кто-то нажмёт «Отправить сейчас» в менеджере, — то есть письма уходят, когда кто-то смотрит в экран.
* * * * * cd /путь/к/сайту && /usr/bin/php core/components/pageblocks/bin/mail-queue.php >> /dev/null 2>&1
30 4 * * * cd /путь/к/сайту && /usr/bin/php core/components/pageblocks/bin/mail-queue.php --purgeРаз в минуту — не чересчур: проход без работы это один индексированный запрос, а письмо, которого кто-то ждёт, не должно лежать пять минут из-за круглого числа в crontab.
Флаги: --limit=N, --context=KEY, --purge, --dry (показать очередь, ничего не отправляя).
Запускать от пользователя веб-сервера, а не от root
Иначе получим root-овские файлы кэша, которые сайт потом не сможет перезаписать.
Два обработчика одновременно
Запись забирается условным UPDATE: оба процесса видят её свободной, оба пытаются взять, база отдаёт ровно одному. Проигравший получает ноль затронутых строк и идёт дальше. Без этого cron раз в минуту при проходе длиной полторы минуты отправлял бы всё дважды.
Запись, занятая дольше пятнадцати минут, считается брошенной — процесс умер на отправке — и возвращается в очередь. Это может дать дубль: умерший процесс мог успеть дойти до SMTP. Выбор сознательный — вторая копия лицензионного ключа это досада, а ключ, который не пришёл, — возврат денег.
Журнал в менеджере
Пункт меню Почта — отдельная страница компонента, рядом с «Многоразовыми блоками» и «Корзиной». Вкладкой конструктора журнал был раньше: в конструкторе строят схему, а журнал смотрят, когда письмо не пришло, — и спрашивает об этом не интегратор, а тот, кто ведёт сайт.
Намеренно не PRO: платят за работу раздела данных, а почта — это инфраструктура, и сайту на бесплатной сборке видеть, ушло письмо или нет, нужно ровно так же, как любому другому.
Писать в журнал руками нельзя — ни создать запись, ни отредактировать. Журнал, в который можно вписать что угодно, отвечает на вопрос «отправлялось ли это?» последней правкой оператора, а это хуже, чем отсутствие журнала.
Доступны: фильтр по состоянию (включая Требуют внимания — неудачные плюс те, что уже один раз промахнулись), фильтр по источнику, поиск, просмотр письма целиком, Отправить снова, Отправить сейчас и Удалить старые записи.
Тело письма со списком не передаётся: двадцать строк HTML-счёта на запись — это мегабайты по сети ради списка тем. Просмотрщик запрашивает одну запись отдельно и показывает тело в iframe с пустым sandbox — это чужая разметка, и ей не место в DOM менеджера ни по соображениям безопасности, ни по вёрстке.
Настройки
| Настройка | По умолчанию | Что делает |
|---|---|---|
pageblocks_mail_max_attempts | 5 | сколько раз пытаться |
pageblocks_mail_batch | 50 | писем за один проход cron |
pageblocks_mail_throttle | 250000 | пауза между письмами, в микросекундах |
pageblocks_mail_keep_days | 90 | сколько хранить завершённые записи; 0 — не чистить никогда |
pageblocks_site_mode | prod | dev включает предохранитель |
pageblocks_admin_email | — | кому всё ещё можно писать в dev |
pageblocks_dev_mail_groups | Administrator | чьим группам всё ещё можно писать в dev |
Предохранитель для разработки
На сайте в режиме dev Mail пропускает только письма на адрес pageblocks_admin_email и в группы из pageblocks_dev_mail_groups. Остальным не уходит ничего, запись помечается skipped, а send() возвращает true.
Так и задумано: вызывающий код считает false ошибкой доставки, и рассылка из-за режима площадки отписала бы живых подписчиков.
Залили дамп боевой базы на dev — первым делом поставьте pageblocks_site_mode = dev
Из дампа приедет prod, и предохранитель окажется выключен. Отсутствие настройки тоже читается как prod.
Хранение и очистка
В pb_mails лежат тела писем, из-за чего это самая тяжёлая таблица компонента. --purge удаляет записи, которые свой срок отслужили.
Очистка трогает только завершённые записи: отправленные, пропущенные и окончательно неудачные. Письмо, которое всё ещё ждёт в очереди, не становится устаревшим только потому, что его собрали две недели назад, — удалить его значило бы молча потерять почту, а ровно для этого журнал и существует.
📚 Итог
Mail— удобный класс для отправки писем с Fenom-шаблонами и вложениями.- Использует настройки MODX для значений
from,fromNameиsender. - Позволяет легко настраивать адреса, копии и формат отправки.
- Каждое письмо попадает в журнал, а сбой повторяется с растущей задержкой.