ttbooking/mailspoon
Composer 安装命令:
composer require ttbooking/mailspoon
包简介
Simple Mailgun compatible IMAP to HTTP webhook relay for Laravel.
README 文档
README
Простое реле IMAP → HTTP-вебхук, совместимое с Mailgun. Пакет для Laravel.
Mailspoon подключается к обычному IMAP-ящику, следит за появлением новых писем и
пересылает каждое входящее письмо на HTTP-эндпоинт, используя тот же формат
данных и схему подписи, что и входящие вебхуки Mailgun. Это позволяет
продолжать обрабатывать почту привычным Mailgun-эндпоинтом (например,
laravel-mailbox), даже когда
письма приходят по обычному IMAP, а не через Mailgun.
Устанавливается composer-пакетом в любое приложение
Laravel 13; чтение почты — на базе
ImapEngine
(directorytree/imapengine-laravel).
Как это работает
Mailspoon работает по схеме store-and-forward: чтение ящика отделено от доставки вебхука, поэтому медленный или недоступный эндпоинт не блокирует однопоточное чтение почты.
IMAP-ящик ──(mailspoon:pull / mailspoon:sentry)──▶ событие MessageReceived
│
└─▶ StoreIncomingMessage: архивирует сырой MIME + создаёт запись (pending)
│
└─▶ письмо сразу помечается прочитанным (\Seen)
mailspoon:deliver (отдельно, по планировщику)
│
└─▶ берёт pending из хранилища ──POST (body-mime + подпись Mailgun)──▶ ваш эндпоинт
│
└─▶ статус delivered, либо failed (повтор на следующем запуске)
- Команда забирает непрочитанные письма из папки ящика (по умолчанию INBOX)
и на каждое диспатчит событие
MessageReceivedизImapEngine. - Слушатель
StoreIncomingMessageсохраняет сырой MIME в хранилище, создаёт запись о письме со статусомpendingи сразу помечает письмо прочитанным — приём надёжно зафиксирован локально. - Команда
mailspoon:deliverнезависимо разбираетpending-записи и шлёт POST на эндпоинт. Успех →delivered; ошибка →attempts++иfailed, письмо переотправится на следующем запуске (доMAILSPOON_MAX_ATTEMPTS).
Дедупликация по Message-Id (или хешу письма, если заголовка нет) исключает
повторную обработку одного и того же сообщения.
Содержимое вебхука
Запрос отправляется как application/x-www-form-urlencoded и содержит
следующие поля, повторяющие входящий MIME-вебхук Mailgun:
| Поле | Описание |
|---|---|
body-mime |
Полный исходный MIME-текст письма. |
timestamp |
Unix-метка момента отправки вебхука. |
token |
Случайный hex-токен длиной 50 символов, уникальный для каждого запроса. |
signature |
HMAC-SHA256(timestamp + token, MAILSPOON_KEY) — проверяется на стороне получателя. |
Проверяйте подпись на своей стороне так же, как для Mailgun:
hash_hmac('sha256', $timestamp . $token, $signingKey).
Помимо полей формы запрос несёт служебные HTTP-заголовки:
| Заголовок | Описание |
|---|---|
X-Mailspoon-Message-Id |
Message-Id письма (отсутствует, если у письма нет этого заголовка). |
X-Mailspoon-Attempt |
Номер попытки доставки этой записи, начиная с 1. |
Доставка — at-least-once. Успехом считается полученный ответ 2xx: если
эндпоинт обработал запрос, но ответ потерялся (таймаут, обрыв), письмо будет
отправлено повторно. Обработчики на принимающей стороне должны быть
идемпотентными — заголовок X-Mailspoon-Message-Id позволяет отбросить
дубликат ещё до разбора MIME.
Требования
- PHP 8.3+
- Приложение Laravel 13 (хост)
- IMAP-ящик
- HTTP-эндпоинт для приёма пересылаемых писем
- База данных — хранит записи о письмах и статус доставки
- Диск хранилища (
config/filesystems.php) с'throw' => true— для архива сырого MIME
Установка
composer require ttbooking/mailspoon # конфиг Mailspoon → config/mailspoon.php php artisan vendor:publish --tag=mailspoon-config # конфиг IMAP-подключений → config/imap.php php artisan vendor:publish --provider="DirectoryTree\ImapEngine\Laravel\ImapServiceProvider" php artisan migrate
Миграции пакета применяются автоматически; при желании их можно скопировать в
приложение: php artisan vendor:publish --tag=mailspoon-migrations.
Диск архива: обязателен 'throw' => true
Архив .eml — единственная копия письма после пометки прочитанным, поэтому
ошибки записи/чтения/удаления не должны подавляться Flysystem. Mailspoon
отказывается работать с диском, у которого 'throw' => false (значение по
умолчанию в свежем Laravel). Включите его для выбранного диска в
config/filesystems.php:
'local' => [ 'driver' => 'local', 'root' => storage_path('app/private'), 'serve' => true, 'throw' => true, ],
Конфигурация
IMAP-подключение (config/imap.php)
IMAP_HOST=imap.example.com IMAP_PORT=993 IMAP_USERNAME=your-username IMAP_PASSWORD=your-password IMAP_ENCRYPTION=ssl # ssl | tls | starttls | false
Дополнительные необязательные переменные: IMAP_TIMEOUT, IMAP_DEBUG,
IMAP_VALIDATE_CERT, IMAP_AUTHENTICATION, а также настройки прокси
(IMAP_PROXY_SOCKET, IMAP_PROXY_USERNAME, IMAP_PROXY_PASSWORD,
IMAP_PROXY_REQUEST_FULLURI).
В config/imap.php под ключом mailboxes можно описать несколько ящиков;
встроенный называется default.
Адрес пересылки (config/mailspoon.php)
MAILSPOON_ENDPOINT=https://example.com/laravel-mailbox/mailgun/mime MAILSPOON_KEY=key-55c5c5c5c55f55ca5cd5f55d5c555c55
MAILSPOON_ENDPOINT— URL, который принимает пересылаемые письма.MAILSPOON_KEY— общий секрет для подписи каждого запроса.
Маршрутизация ящиков (config/mailspoon.php)
Каждому ящику можно назначить собственный эндпоинт и ключ подписи — карта
routes в опубликованном конфиге, ключ — имя ящика из config/imap.php:
'routes' => [ 'support' => [ 'endpoint' => 'https://support.example.com/api/mailgun/mime', 'key' => 'key-support', // Необязательно: маркер просмотра и фильтры конкретно для этого // ящика — переопределяют глобальные `mark` и `filters` (см. ниже). 'mark' => 'keyword:Mailspoon', 'filters' => ['allow' => ['subject' => ['/invoice/i']]], ], 'billing' => [ 'endpoint' => 'https://billing.example.com/api/mailgun/mime', 'key' => 'key-billing', ], 'archive-mailbox' => [ // Приостановить ящик, не удаляя его настройки. 'enabled' => false, ], ],
Опции маршрута: endpoint и key (откат на глобальные), mark (маркер
просмотра, см. «Ящики-люди»), filters (заменяют глобальные целиком, см.
«Фильтрация писем») и enabled (см. «Приостановка ящика»).
Ящик без маршрута (или с частичным маршрутом) использует глобальные
MAILSPOON_ENDPOINT/MAILSPOON_KEY; если маршруты заданы для всех ящиков,
глобальные значения можно не задавать вовсе — mailspoon:doctor проверяет,
что каждый ящик резолвится хоть во что-то. Эндпоинт фиксируется в записи в момент
захвата письма, а ключ подписи выбирается в момент доставки — поэтому ротация
ключа действует и на ещё не доставленные письма, а смена эндпоинта — только на
новые.
Приостановка ящика (enabled)
Чтобы временно остановить или отложить чтение конкретного ящика, не удаляя его
настройки, задайте в маршруте 'enabled' => false:
'routes' => [ 'legacy-support' => [ 'enabled' => false, ], ],
Это касается только захвата: mailspoon:pull сразу завершается, не подключаясь
к IMAP и не двигая UID-курсор; mailspoon:sentry не запускает ни pull, ни
IDLE-наблюдение; а cron-poll из schedule.pull для этого ящика не регистрируется
в планировщике. Письма, уже сохранённые в архиве, продолжают доставляться
mailspoon:deliver как обычно. По умолчанию ящик включён (флаг можно опустить).
Прямой запуск imap:watch пакета imapengine-laravel флаг не проверяет.
Фильтрация писем (config/mailspoon.php)
Правила include/exclude применяются до захвата: отфильтрованное письмо
помечается просмотренным, но не попадает ни в журнал, ни в архив, ни на
эндпоинт. Карта filters — глобально или на маршруте (маршрутная заменяет
глобальную целиком):
'filters' => [ 'allow' => [ 'subject' => ['/⚡/u'], // регэксп (с разделителями) 'from' => ['*@trusted.com'], // или wildcard без учёта регистра ], 'deny' => [ 'from' => ['no-reply@*', 'mailer-daemon@*'], 'header' => ['Auto-Submitted' => 'auto-*'], 'has_attachment' => false, ], ],
deny приоритетнее allow; пустой allow пропускает всё. Поля: from,
subject, header, has_attachment. Кривое правило (битый регэксп,
неизвестное поле) ловится на старте и в mailspoon:doctor, а не молча
пропускает письма. Проверить правила на конкретном письме до запуска чтения —
mailspoon:filter-test.
Каждое отфильтрованное письмо оставляет след: запись в логе Laravel и событие
MessageFiltered — слишком строгое allow-правило видно по логу,
а не по тишине в журнале.
Пример: пропускать только подтверждения о прочтении (MDN) — формат
multipart/report с report-type=disposition-notification:
'filters' => [ 'allow' => [ 'header' => ['Content-Type' => '/report-type=disposition-notification/i'], ], ],
Ящики-люди: маркер просмотренного (mark)
По умолчанию mailspoon помечает обработанные письма прочитанными (\Seen —
его курсор), что годится для ящика-робота. Если ящик читают люди (общий ящик
операторов), прочитанность трогать нельзя — настройка mark глобально или на
маршруте:
'routes' => [ 'operators' => [ 'endpoint' => 'https://crm.example.com/api/mailgun/mime', 'key' => 'key-operators', 'mark' => 'keyword:Mailspoon', 'filters' => ['allow' => ['subject' => ['/⚡/u']]], ], ],
seen(дефолт) — текущее поведение;keyword:<имя>— кастомный IMAP-кейворд: невидим в почтовых клиентах, курсор живёт на сервере; сервер должен разрешать кастомные кейворды (PERMANENTFLAGS \*— Dovecot, Gmail, Exchange умеют);none— письмо не трогается вовсе; позиция отслеживается UID-курсором в БД (таблицаrelay_cursors, сбрасывается при сменеUIDVALIDITY). Курсор продвигает толькоmailspoon:pull; IDLE-режим (mailspoon:sentry) видит лишь новые поступления и захватывает их в журнал, но курсор не двигает — после рестарта pre-fetch перечитает диапазон с последнего pull, дедуп отбросит уже захваченное (повторной доставки не будет, только повторное скачивание). Для cron-poll эта оговорка не действует: там каждый запуск — pull.
Тонкость стыка none × retention: дедуп-записи журнала живут
MAILSPOON_RETENTION_DAYS дней. Если сервер сбросит UIDVALIDITY (переезд,
пересоздание папки) после того, как записи о старых письмах уже вычищены,
UID-курсор обнулится и эти письма будут захвачены и доставлены повторно —
дедупу не с чем их сравнить. Ситуация редкая (нужны оба события сразу), но на
ящике с mark: none и короткой retention стоит про неё помнить; защита на
принимающей стороне — те же идемпотентные обработчики.
Маркер ставится всем просмотренным письмам, включая отфильтрованные —
иначе они перечитывались бы каждым запуском; на эндпоинт уходят только
прошедшие фильтр. Первый запуск на ящике с keyword:/none просмотрит весь
ящик (а не только непрочитанное) — это сознательно: обрабатывается вся
история, совпавшая с фильтром.
Хранилище и доставка (config/mailspoon.php)
MAILSPOON_ARCHIVE_DISK=local # диск из config/filesystems.php для сырого MIME MAILSPOON_ARCHIVE_PATH=mailspoon # префикс пути внутри диска MAILSPOON_RETENTION_DAYS=3 # срок хранения записей и MIME; 0 отключает очистку MAILSPOON_PRUNE_CRON="0 3 * * *" # расписание очистки при включённом retention MAILSPOON_TIMEOUT=15 # общий таймаут запроса доставки, сек MAILSPOON_CONNECT_TIMEOUT=3 # таймаут на TCP-handshake, сек MAILSPOON_TRIES=3 # быстрых in-process повторов на одну попытку MAILSPOON_BACKOFF=60,300,900,3600 # пауза между запусками, сек, по номеру попытки MAILSPOON_MAX_ATTEMPTS=10 # сколько попыток доставки, прежде чем сдаться
MAILSPOON_ARCHIVE_DISK/MAILSPOON_ARCHIVE_PATH— куда складывается архив.eml; диск обязан иметь'throw' => true(см. выше).MAILSPOON_RETENTION_DAYS— сколько дней хранить завершённые записи вместе с.eml; по умолчанию3, значение0отключает автоматическую очистку.MAILSPOON_PRUNE_CRON— расписание штатной команды Laravelmodel:prune.MAILSPOON_TIMEOUT/MAILSPOON_CONNECT_TIMEOUT— общий таймаут запроса и отдельный лимит на установление TCP-соединения, чтобы зависший handshake не подвешивал воркер.MAILSPOON_TRIES— короткие повторы внутри одной попытки для мгновенных блипов (сеть, 5xx, 429); постоянные 4xx не повторяются.MAILSPOON_BACKOFF— растущая пауза между запускамиmailspoon:deliver: упавшее письмо берётся повторно только после задержки, соответствующей номеру попытки (последнее значение применяется для всех дальнейших).MAILSPOON_MAX_ATTEMPTS— после стольких неудачных попыток письмо перестаёт переотправляться и остаётся в статусеfailedдля ручного разбора.
Карты — в опубликованном конфиге
Структурные настройки (например, расписание cron-poll по ящикам) задаются
обычным PHP в config/mailspoon.php — без сериализации в env:
'schedule' => [ // ... 'pull' => [ 'default' => '*/5 * * * *', 'secondary' => '0 * * * *', ], ],
Опубликованный конфиг должен сохранять полную структуру секций: merge с дефолтами пакета выполняется только по верхнему уровню.
Маршруты из хранилища (Mailspoon::register())
Если набор ящиков не статичен — например, приложение использует Mailspoon как
читца почты и каждый клиент регистрирует свой ящик и вебхук через UI, — маршруты
можно задавать в рантайме, не редактируя config/mailspoon.php. Фасад
Mailspoon повторяет Imap из imapengine: метод register() добавляет или
переопределяет маршрут для ящика.
use TTBooking\Mailspoon\Facades\Mailspoon; Mailspoon::register('tenant-42', [ 'endpoint' => 'https://tenant-42.example.com/api/mailgun/mime', 'key' => 'key-42', 'schedule' => '*/5 * * * *', // cron-poll этого ящика (как запись schedule.pull) 'enabled' => true, // та же семантика, что у маршрута в конфиге 'mark' => 'keyword:Mailspoon', 'filters' => ['allow' => ['subject' => ['/invoice/i']]], ]);
Зарегистрированный маршрут имеет приоритет над одноимённым в конфиге и заменяет
его целиком (не сливается). Опции — те же, что в карте routes, плюс
необязательный schedule: ящик с расписанием попадает в cron-poll наравне с
записями schedule.pull, так что новый ящик начинает опрашиваться без правки
конфига. Семантика времени прежняя: endpoint фиксируется при захвате, key
выбирается при доставке, mark/filters — при захвате.
Динамические IMAP-подключения (Imap::register())
Маршрут описывает доставку. Чтобы pull-режим тоже работал из хранилища, ящику
нужно само IMAP-подключение: mailspoon:pull/:sentry соединяются через
Imap::mailbox($name), который резолвит config/imap.php. Своей абстракции для
этого не нужно — у imapengine есть публичный Imap::register($name, $config),
кладущий подключение в его менеджер на время процесса. Регистрируйте подключение
и маршрут рядом, в boot() своего сервис-провайдера:
use DirectoryTree\ImapEngine\Laravel\Facades\Imap; use TTBooking\Mailspoon\Facades\Mailspoon; public function boot(): void { // Выполняется в каждом процессе, включая фоновый mailspoon:pull из // планировщика, — поэтому подключение доступно и там. foreach (Mailbox::all() as $box) { // ваша Eloquent-модель Imap::register($box->mailbox, $box->imap_config); // подключение Mailspoon::register($box->mailbox, [ // доставка + опрос 'endpoint' => $box->endpoint, 'key' => $box->key, 'schedule' => $box->poll_cron, 'enabled' => $box->enabled, ]); } }
Так покрыты все точки входа: mailspoon:pull (в т.ч. фоновый), mailspoon:sentry
(регистрация в одном процессе переживает вложенные pull и vendor imap:watch)
и mailspoon:deliver (к IMAP не обращается).
Что учесть:
boot()выполняется на каждый процесс — кэшируйте выборку из БД либо регистрируйте только подrunningInConsole()/нужные команды, чтобы не делать запрос на каждый веб-реквест.- IMAP-пароли в БД храните через encrypted-cast.
- Прямой запуск vendor
imap:watchмимо команд Mailspoon подключение из БД не получит — регистрируйте его сами или держите ящик вconfig/imap.php.
Использование
Mailspoon предоставляет команды чтения (mailspoon:pull, mailspoon:sentry)
и команду доставки (mailspoon:deliver). Аргумент mailbox — это имя ящика из
config/imap.php (для встроенного используйте default). Необязательный
аргумент folder выбирает папку, отличную от INBOX.
mailspoon:pull — разовая проверка
Забирает все текущие непрочитанные письма, сохраняет их и завершается.
php artisan mailspoon:pull default
php artisan mailspoon:pull default "INBOX/Archive"
Опции:
--with=— список через запятую частей письма для подгрузки. Если опция не задана или пуста, используютсяflags,headers,body, необходимые для сохранения полного сырого MIME.--chunk=— сколько писем забирать одной IMAP-командой (по умолчаниюMAILSPOON_PULL_CHUNK, 100). Письма выбираются пачками от старых к новым: одинFETCHс тысячами UID (большой бэклог, первый прогон с маркеромkeyword:/none) превышает лимит длины команды сервера — Dovecot отвечаетBAD ... Too long argument. Дляnone-маркера UID-курсор сохраняется после каждой пачки, так что прерванный прогон бэклога продолжится с места остановки.
Подходит для запуска по расписанию (cron), когда долгоживущий процесс не нужен.
mailspoon:sentry — забрать накопившееся и следить дальше
Сначала один раз выполняет mailspoon:pull, чтобы сохранить накопившиеся
письма, затем начинает следить за ящиком в реальном времени (через IMAP IDLE) и
сохраняет письма по мере поступления. Это рекомендуемый способ запускать
Mailspoon как постоянный воркер.
php artisan mailspoon:sentry default
Опции:
--method=idle— метод слежения (по умолчаниюidle).--with=— части письма для подгрузки (по умолчаниюflags,headers,body).--timeout=30— таймаут IDLE в секундах.--attempts=5— число попыток переподключения.--debug=false— включить отладочный вывод.
Запускайте под супервизором процессов (systemd, Supervisor и т. п.), чтобы он перезапускался автоматически:
[program:mailspoon] command=php /path/to/app/artisan mailspoon:sentry default autostart=true autorestart=true
Команда
imap:watch(только слежение, без предварительного разбора) предоставляется самим ImapEngine;mailspoon:sentry— это обёртка надmailspoon:pull+imap:watch.
Команды чтения только сохраняют письма (архив + запись
pending) и помечают их прочитанными. Сама доставка на эндпоинт выполняется отдельно — командойmailspoon:deliver.
mailspoon:deliver — доставка сохранённых писем
Разбирает pending-записи (и ранее проваленные, у которых прошёл backoff и не
исчерпан лимит попыток), читает сырой MIME из архива и шлёт подписанный POST на
эндпоинт. Ретрай двухуровневый:
- внутри попытки — короткие повторы (
MAILSPOON_TRIES) для мгновенных сетевых блипов и ответов 5xx/429, с ограничением таймаутов (MAILSPOON_TIMEOUT,MAILSPOON_CONNECT_TIMEOUT); - между запусками — упавшее письмо переносится на потом через
next_attempt_atпо расписаниюMAILSPOON_BACKOFF, без блокирующих пауз в воркере.
Так зависший или медленный эндпоинт никогда не тормозит чтение ящика.
php artisan mailspoon:deliver php artisan mailspoon:deliver --limit=100 --max-attempts=5 php artisan mailspoon:deliver --dry-run
Опции:
--limit=50— максимум писем за один запуск.--max-attempts=— переопределитьMAILSPOON_MAX_ATTEMPTS.--dry-run— показать таблицей, что и куда ушло бы (эндпоинт, источник ключа, состояние архива), не отправляя запросов и не меняя записи.
Команда — разовая (one-shot); запускать её периодически проще всего
планировщиком (см. ниже), который уже вызывает mailspoon:deliver с
withoutOverlapping().
mailspoon:replay — переотправка писем
Сбрасывает записи журнала обратно в pending — фактическую отправку выполнит
ближайший запуск mailspoon:deliver (сырой MIME читается из архива, лезть в
ящик заново не нужно). Счётчик попыток обнуляется, так что переотправляются и
письма с исчерпанным лимитом.
php artisan mailspoon:replay "<message-id@example.com>" # конкретные письма php artisan mailspoon:replay --failed # все проваленные php artisan mailspoon:replay --failed --mailbox=support # только один ящик
Replay — явное действие оператора: дедупликация сознательно обходится, можно переотправить и уже доставленное письмо (например, после потери данных на стороне получателя).
mailspoon:doctor — диагностика конфигурации
Проверяет всю цепочку до запуска воркера: наличие таблицы журнала, запись и
чтение на диске архива (включая обязательный 'throw' => true), эндпоинт и
ключ каждого ящика (маршрут или глобальные), реальный IMAP-логин и доступность
эндпоинта. Печатает образец подписи для сверки ключа с получателем
(MAILBOX_MAILGUN_KEY у laravel-mailbox). Завершается ненулевым кодом при
любой провальной проверке — удобно как preflight в деплое.
Отдельная проверка schedule сообщает cron-расписание ящика. Её отсутствие —
не ошибка (ящик может читаться mailspoon:sentry или ручным mailspoon:pull),
поэтому это предупреждение (строка !), а не провал: код возврата остаётся
нулевым, в JSON-отчёте статус проверки — warn. Расписание проверяется и для
паузнутого маршрута (enabled => false): doctor — preflight, его часто гоняют
во время настройки, пока ящик ещё выключен, поэтому пауза без расписания тоже
даёт предупреждение, а не прячет недонастройку.
php artisan mailspoon:doctor # все ящики из config/imap.php php artisan mailspoon:doctor support # только указанные php artisan mailspoon:doctor --send # + подписанное тестовое письмо
По умолчанию эндпоинт только пробуется OPTIONS-запросом (без тестовой почты в
принимающее приложение); --send отправляет полноценное подписанное письмо с
заголовком X-Mailspoon-Doctor: true и требует ответа 2xx.
mailspoon:filter-test — сухой прогон фильтров
Прогоняет письмо через filters ящика без захвата: не ходит в журнал, не
архивирует, не помечает письмо и ничего не доставляет. Печатает вердикт и
правило, которое его решило — какой deny отбросил письмо или какой allow
его пропустил. Удобно при настройке правил, до запуска чтения.
php artisan mailspoon:filter-test support --file=sample.eml # сырой MIME из файла php artisan mailspoon:filter-test support --uid=42 # письмо по UID из ящика
✗ FILTERED — message would be dropped (denied by from rule [no-reply@*])
Работает и на выключенном ящике (enabled => false): вопрос «отфильтруется
ли это письмо» одинаково валиден для маршрута, который ещё только настраивают.
Команда — тонкая обёртка над сервисом FilterTester (см. ниже), так что тот же
вердикт можно получить из дашборда. Матчер берётся живой — из конфига или
рантайм-Mailspoon::register(), — поэтому результат точно совпадает с тем, что
решит захват. --file ничего не требует от IMAP; --uid тянет письмо из ящика.
Вызов операций из приложения (сервисы)
За командами mailspoon:doctor/:replay/:deliver стоят сервисы, которые
можно вызвать из своего кода (контроллера, Job'а) и получить структурный
результат вместо текста консоли — удобно, когда ящики и маршруты заводятся
динамически (см. «Маршруты из хранилища») и обслуживаются из веб-интерфейса.
Mailspoon HTTP-слой не шипит: контроллеры и UI строит хост, ниже — примеры.
Каждый результат реализует Arrayable/JsonSerializable, поэтому годится прямо
для response()->json().
use DirectoryTree\ImapEngine\FileMessage; use TTBooking\Mailspoon\Services\Doctor; use TTBooking\Mailspoon\Services\Replay; use TTBooking\Mailspoon\Services\ReplayCriteria; use TTBooking\Mailspoon\Services\Deliverer; use TTBooking\Mailspoon\Services\FilterTester; // Диагностика — DoctorReport со списком проверок (mailbox/name/status/message). $report = app(Doctor::class)->run(['support']); // пусто = все ящики; ->run([], send: true) — с подписанным письмом return response()->json($report); // { "ok": false, "checks": [ ... ] } // Переотправка — ReplayResult { count, messages: [...] }. $result = app(Replay::class)->run(new ReplayCriteria(failed: true, mailbox: 'support')); // Пустой критерий (ни id, ни failed) бросает InvalidArgumentException — мапьте в 422. // Принудительный флаш — DeliverySummary { delivered, failed, total }. $summary = app(Deliverer::class)->run(limit: 50); // Сухой прогон фильтров — FilterTestResult { passes, decision, field, pattern, reason }. // Принимает любой MessageInterface; для загруженного .eml — FileMessage, без IMAP. $verdict = app(FilterTester::class)->test('support', new FileMessage($request->getContent())); return response()->json($verdict); // { "passes": false, "decision": "denied_by_rule", ... } // Работает и для выключенного ящика; малформленное правило бросает InvalidArgumentException.
На что обратить внимание:
DoctorиDelivererделают сетевые операции (IMAP-логин, HTTP-проба или доставка) синхронно и с таймаутами — в вебе запускайте их через очередь/Job, а не в реквест-цикле.ReplayиDelivererменяют данные (сбрасывают/доставляют записи) — авторизацию таких действий обеспечивает хост.FilterTesterничего не делает — чистая оценка без IMAP, БД и сети, без побочных эффектов, — поэтому его безопасно звать прямо в реквест-цикле.
События
Реле остаётся «тупой трубой»: оно не шлёт уведомлений и не строит метрик, но объявляет хост-приложению о двух ситуациях, которые иначе остались бы незамеченными. Оба события дублируются записью в лог Laravel, так что минимум наблюдаемости есть и без слушателей.
TTBooking\Mailspoon\Events\MessageFiltered— письмо отклонено правиламиfilters(свойства:message,mailbox). Отфильтрованное письмо помечается просмотренным, но не попадает ни в журнал, ни в архив — событие — его единственный след. По умолчанию пакет сам логирует его (info) дефолтным слушателемLogFilteredMessage;MAILSPOON_LOG_FILTERED=falseотключает лог, оставляя логирование на усмотрение подписчика события.TTBooking\Mailspoon\Events\DeliveryPermanentlyFailed— письмо исчерпалоMAILSPOON_MAX_ATTEMPTSи больше не будет переотправляться (свойство:message— модельRelayedMessage). Запись остаётся в журнале со статусомfailedдо ручногоmailspoon:replay; лог-запись —error.
Подписка — штатными средствами Laravel, например уведомление о застрявшем письме:
use Illuminate\Support\Facades\Event; use TTBooking\Mailspoon\Events\DeliveryPermanentlyFailed; Event::listen(function (DeliveryPermanentlyFailed $event) { Notification::route('slack', config('services.slack.ops')) ->notify(new RelayStuckNotification($event->message)); });
Запуск и расписание
Mailspoon регистрирует свои задачи в планировщике хост-приложения. Если
системный cron для schedule:run ещё не настроен, добавьте одну строку:
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
Что именно планируется, задаётся в config/mailspoon.php → schedule
(все задачи — с withoutOverlapping()):
mailspoon:deliver— включён по умолчанию (MAILSPOON_DELIVER_CRON, по умолчанию каждую минуту). Нужен в любом режиме, поскольку чтение только сохраняет письма. Чтобы отключить — задайтеMAILSPOON_DELIVER_CRONпустым.mailspoon:pullпо ящикам — картаимя ящика => cronв опубликованном конфиге (ключschedule.pull), по умолчанию пуста.- Очистка журнала и архива — по умолчанию включена с retention 3 дня.
При
MAILSPOON_RETENTION_DAYS > 0запускаетсяmodel:pruneпо расписаниюMAILSPOON_PRUNE_CRON(по умолчанию ежедневно в 03:00). Записьrelayed_messagesудаляется только вместе со связанным.eml. Очищаются только успешно доставленные письма; записиpendingиfailedсохраняются для повторной доставки и ручного разбора.
Отсюда два режима эксплуатации:
| Режим | Чтение | Демон / supervisor | Латентность |
|---|---|---|---|
| Cron-poll | mailspoon:pull по карте schedule.pull |
не нужен | = интервал cron |
| Realtime | mailspoon:sentry (IMAP IDLE) под supervisor |
нужен для watcher | секунды |
В обоих режимах доставку выполняет запланированный mailspoon:deliver —
отдельный демон или очередь для неё не требуются.
Связка с Laravel Mailbox
Mailspoon отлично сочетается с
beyondcode/laravel-mailbox.
Поскольку Mailspoon шлёт запрос в точности так же, как входящий MIME-вебхук
Mailgun, приложение может принимать пересылаемые письма штатным
mailgun-драйвером Laravel Mailbox — никакого кастомного кода для приёма не
требуется. Mailspoon можно установить как в отдельное приложение-реле, так и
прямо в приложение с Laravel Mailbox — тогда оно само читает свой ящик и
шлёт вебхук на собственный эндпоинт.
В приложении-получателе с установленным Laravel Mailbox:
MAILBOX_DRIVER=mailgun MAILBOX_MAILGUN_KEY=key-55c5c5c5c55f55ca5cd5f55d5c555c55
а в Mailspoon направьте реле на его эндпоинт и используйте тот же ключ, чтобы подписи совпадали:
MAILSPOON_ENDPOINT=https://your-app.com/laravel-mailbox/mailgun/mime # MAILSPOON_KEY должен совпадать с MAILBOX_MAILGUN_KEY MAILSPOON_KEY=key-55c5c5c5c55f55ca5cd5f55d5c555c55
Дальше обрабатывайте письма как обычно через маршруты Laravel Mailbox:
use BeyondCode\Mailbox\Facades\Mailbox; use BeyondCode\Mailbox\InboundEmail; Mailbox::from('sender@example.com', function (InboundEmail $email) { $subject = $email->subject(); // ... });
Итоговый поток: IMAP-ящик → Mailspoon → вебхук Mailgun → Laravel Mailbox → ваши обработчики.
Лицензия
Mailspoon распространяется по лицензии MIT.
ttbooking/mailspoon 适用场景与选型建议
ttbooking/mailspoon 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 18 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 06 月 10 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「imap」 「laravel」 「mailgun」 「webhook」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 ttbooking/mailspoon 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 ttbooking/mailspoon 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 ttbooking/mailspoon 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
PHP class to access an IMAP mailbox. Specifically uses the Laminas library and not the IMAP extension.
yii2 extension to read and process mails from IMAP and PHP
Send emails from multiple domains with mailgun
Mailgun integration for SilverStripe emails
IMAP client for PHP without php-imap extension dependency
Laravel IMAP client
统计信息
- 总下载量: 18
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 42
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-10