ttbooking/mailspoon

Simple Mailgun compatible IMAP to HTTP webhook relay for Laravel.

Maintainers

Package info

github.com/ttbooking/mailspoon

pkg:composer/ttbooking/mailspoon

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1


README

tests Latest Stable Version License

Простое реле 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 (повтор на следующем запуске)
  1. Команда забирает непрочитанные письма из папки ящика (по умолчанию INBOX) и на каждое диспатчит событие MessageReceived из ImapEngine.
  2. Слушатель StoreIncomingMessage сохраняет сырой MIME в хранилище, создаёт запись о письме со статусом pending и сразу помечает письмо прочитанным — приём надёжно зафиксирован локально.
  3. Команда 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 — расписание штатной команды Laravel model: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.phpschedule (все задачи — с 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.