ttbooking / mailspoon
Simple Mailgun compatible IMAP to HTTP webhook relay for Laravel.
Requires
- php: ^8.3
- directorytree/imapengine-laravel: ^1.3
- illuminate/console: ^13.0
- illuminate/container: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/events: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- orchestra/testbench: ^11.1
- pestphp/pest: ^4.4
- pestphp/pest-plugin-laravel: ^4.1
- dev-master
- v3.4.0
- v3.3.2
- v3.3.1
- v3.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- 2.x-dev
- v2.1.1
- v2.1.0
- v2.0.1
- v2.0.0
- v1.0.0
- dev-feature/relay-observability
- dev-feature/06-after-relay-actions
- dev-fix/chunked-pull-fetch
- dev-fix/route-lookup-and-optional-global-relay
- dev-chore/ci-php85
- dev-chore/ci-windows
- dev-feature/04-22-filters-and-marks
- dev-feature/15-17-replay-doctor
- dev-chore/ci-workflow
- dev-feature/03-mailbox-routing
This package is auto-updated.
Last update: 2026-06-12 09:24:10 UTC
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', ], ],
Опции маршрута: endpoint и key (откат на глобальные), mark (маркер
просмотра, см. «Ящики-люди») и filters (заменяют глобальные целиком, см.
«Фильтрация писем»).
Ящик без маршрута (или с частичным маршрутом) использует глобальные
MAILSPOON_ENDPOINT/MAILSPOON_KEY; если маршруты заданы для всех ящиков,
глобальные значения можно не задавать вовсе — mailspoon:doctor проверяет,
что каждый ящик резолвится хоть во что-то. Эндпоинт фиксируется в записи в момент
захвата письма, а ключ подписи выбирается в момент доставки — поэтому ротация
ключа действует и на ещё не доставленные письма, а смена эндпоинта — только на
новые.
Фильтрация писем (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, а не молча
пропускает письма.
Каждое отфильтрованное письмо оставляет след: запись в логе 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 предоставляет команды чтения (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 в деплое.
php artisan mailspoon:doctor # все ящики из config/imap.php php artisan mailspoon:doctor support # только указанные php artisan mailspoon:doctor --send # + подписанное тестовое письмо
По умолчанию эндпоинт только пробуется OPTIONS-запросом (без тестовой почты в
принимающее приложение); --send отправляет полноценное подписанное письмо с
заголовком X-Mailspoon-Doctor: true и требует ответа 2xx.
События
Реле остаётся «тупой трубой»: оно не шлёт уведомлений и не строит метрик, но объявляет хост-приложению о двух ситуациях, которые иначе остались бы незамеченными. Оба события дублируются записью в лог Laravel, так что минимум наблюдаемости есть и без слушателей.
TTBooking\Mailspoon\Events\MessageFiltered— письмо отклонено правиламиfilters(свойства:message,mailbox). Отфильтрованное письмо помечается просмотренным, но не попадает ни в журнал, ни в архив — событие и лог-запись (info) — его единственный след.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.