utafitilabs / imap-bundle
Read-only IMAP mailbox watching for Symfony: an IMAP IDLE client, an RFC 822/MIME reader and a long-running watcher, written on PHP built-ins only.
Package info
github.com/utafitilabs/imap-bundle
Type:symfony-bundle
pkg:composer/utafitilabs/imap-bundle
Requires
- php: >=8.4
- ext-iconv: *
- ext-mbstring: *
- ext-openssl: *
- psr/cache: ^2.0 || ^3.0
- psr/log: ^3.0
- symfony/cache: ^7.3 || ^8.0
- symfony/clock: ^7.3 || ^8.0
- symfony/config: ^7.3 || ^8.0
- symfony/console: ^7.3 || ^8.0
- symfony/dependency-injection: ^7.3 || ^8.0
- symfony/framework-bundle: ^7.3 || ^8.0
- symfony/http-kernel: ^7.3 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.2
Suggests
- ext-pcntl: Lets imap:watch stop cleanly on SIGTERM and SIGINT.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-09 07:53:44 UTC
README
This bundle lets a Symfony application react within seconds to mail arriving in a
mailbox: a long-running imap:watch process holds an IMAP connection open with IDLE,
fetches each new message, reads it into a UTF-8 model (addresses, subject, text, HTML,
attachment metadata, SPF/DKIM/DMARC verdicts) and hands it to your handler. It is
read-only by construction and written on PHP built-ins and Symfony components only
— no ext-imap, no third-party MIME parser. Configure a mailbox, write a handler, run the
command.
Part of the utafiti tools.
Contents
- Why
- Install
- A taste
- Resuming after a restart
- Automated mail and bounces
- Documentation
- Requirements
- Compatibility
- Contributing
- Credits
- License
Why
Polling a mailbox from cron answers mail minutes late, and the usual PHP IMAP stacks either need ext-imap (gone from PHP 8.4's core) or pull in a tree of mail libraries you then have to trust with your credentials and your users' mail. This bundle keeps one small, audited code path: a TLS-verified IMAP client, a MIME reader and a watcher that reconnects on its own — and that can never change, move or mark a message as read.
Install
Applications using Symfony Flex:
composer require utafitilabs/imap-bundle
Applications without Symfony Flex — after requiring the package, enable the bundle:
// config/bundles.php return [ // ... UtafitiLabs\ImapBundle\UtafitiLabsImapBundle::class => ['all' => true], ];
That one line registers the imap:watch command, the mailbox watcher, the store of how far
each mailbox has been read, the message reader and the quoted-reply stripper, each
autowirable by class. Then name a mailbox and the
service that handles its mail:
# config/packages/utafitilabs_imap.yaml utafitilabs_imap: mailboxes: replies: dsn: '%env(IMAP_REPLIES_DSN)%' # empty turns the mailbox off handler: App\Mail\ReplyHandler
# .env.local IMAP_REPLIES_DSN=imaps://replies%40example.com:app-password@imap.example.com
A taste
// src/Mail/ReplyHandler.php namespace App\Mail; use UtafitiLabs\ImapBundle\Model\Message; use UtafitiLabs\ImapBundle\Model\MessageHeaders; use UtafitiLabs\ImapBundle\Service\IncomingMailHandlerInterface; use UtafitiLabs\ImapBundle\Service\QuotedReplyStripperService; final class ReplyHandler implements IncomingMailHandlerInterface { public function __construct( private readonly QuotedReplyStripperService $stripper, private readonly ReplyStore $replies, ) { } // Only the headers are fetched so far; say no and the body never leaves the server. public function wants(MessageHeaders $headers): bool { return null !== $headers->inReplyTo && !$this->replies->has($headers->messageId); } public function handle(Message $message): void { $this->replies->add( $message->headers->messageId, $message->headers->from?->email, $this->stripper->strip($message->textBody), // the new text, without the quoted mail ); } }
php bin/console imap:watch replies
The first run catches up on the last three days, then waits with IDLE and hands over each new message within seconds; a restart resumes after the last message read. It reconnects with a doubling delay after any network error and stops cleanly on SIGTERM or Ctrl+C.
Models: Message (headers, textBody, htmlBody, attachments, deliveryFailure),
MessageHeaders (Message-ID, from, to, cc, subject, date, In-Reply-To, References,
authenticationResults, isAutomated(), isDeliveryReport()), Address, Attachment
(file name, type, size — never the content), DeliveryFailure and FailedRecipient. Services: MessageReaderService (raw RFC 822 → Message),
QuotedReplyStripperService (Gmail, Outlook, Apple Mail, Yahoo and Thunderbird quote
forms), MailboxWatcherService, MailboxStateStoreInterface.
Resuming after a restart
The watcher remembers how far it has read each mailbox, as IMAP clients do: the folder's
UIDVALIDITY and the highest UID it has offered
(RFC 3501 §2.3.1.1,
RFC 4549 §4.3). A restart resumes
after that UID, so a message is offered once — handled, declined or failed — whatever
your handler keeps. Only the first run, or a folder whose UIDVALIDITY changed, catches up
on the last catch_up_days.
The state lives in a cache pool, cache.app by default. Out of the box cache.app is a
set of files under var/cache, which a deploy from a fresh image starts without; give the
watcher a pool that survives deploys, such as one in the database:
# config/packages/cache.yaml framework: cache: default_doctrine_dbal_provider: doctrine.dbal.default_connection pools: imap.state: adapter: cache.adapter.doctrine_dbal # config/packages/utafitilabs_imap.yaml utafitilabs_imap: state_pool: imap.state
See docs/index.md for the details and other pools.
Automated mail and bounces
A mailbox that receives replies also receives what machines send back: bounces from mailer-daemon, out-of-office replies, mailing lists. A bounce often carries your own message's Message-ID in In-Reply-To, so a handler that matches replies by thread files it as an answer unless it asks first. Two checks on the headers tell such mail apart, before the body is fetched:
MessageHeaders::isAutomated()— a machine sent it:Auto-Submittedother thanno(RFC 3834), amultipart/report(RFC 6522), an emptyReturn-Path: <>, a mailer-daemon or postmaster sender,X-Autoreply,X-AutorespondorX-Auto-Response-Suppress,Precedence: bulk | junk | auto_reply | list, or a mailing list'sList-IdorList-Unsubscribe.MessageHeaders::isDeliveryReport()— it reports on the delivery of another message: amultipart/reportofreport-type=delivery-status(RFC 3464), or a mailer-daemon or postmaster sender with a subject such as "Mail delivery failed" or "Undelivered Mail Returned to Sender". Every delivery report is also automated.
When a delivery report says delivery failed, the read message carries a
DeliveryFailure: the FailedRecipients (address, one-line reason, status such as
5.1.1, the servers' diagnosticCode), the originalMessageId of the message that came
back, and the first recipient's reason. It is read from the report's delivery-status
part, or from the "address(es) failed" list of an Exim text bounce; a report about a delay
carries none.
public function wants(MessageHeaders $headers): bool { if ($headers->isDeliveryReport()) { return true; // read it, to learn which message bounced } return !$headers->isAutomated() && !$this->replies->has($headers->messageId); } public function handle(Message $message): void { if ($message->headers->isDeliveryReport()) { $failure = $message->deliveryFailure; $sentId = $failure?->originalMessageId; if (null !== $failure && null !== $sentId && $this->sent->has($sentId)) { foreach ($failure->recipients as $recipient) { $this->sent->markUndelivered($sentId, $recipient->email, $recipient->reason); } } return; // a bounce is never an answer } $this->replies->add($message->headers->messageId, $message->headers->from?->email, $message->textBody); }
Documentation
Read the documentation at docs/index.md — configuration reference, the handler contract, resuming after a restart, running the watcher under a process supervisor or in a container, security, providers, and using the client on its own.
Requirements
- PHP 8.4+ with openssl, mbstring and iconv (and pcntl for a clean stop on SIGTERM)
- Symfony 7.3+ / 8
- An IMAP server reachable over TLS (port 993, or STARTTLS on 143)
Compatibility
Any server that speaks IMAP4rev1 (RFC 3501) or IMAP4rev2 (RFC 9051). New mail arrives through IDLE (RFC 2177); a server without IDLE is checked every five minutes instead. Logs in with AUTHENTICATE PLAIN or LOGIN. The client is tested against a recorded Dovecot session.
- Gmail: turn on 2-Step Verification and use an app password as the password.
- Microsoft 365 / Outlook.com: password logins are disabled; they need OAuth 2.0 (XOAUTH2), which is not supported yet — the authenticator interface is ready for it.
Contributing
See CONTRIBUTING.md. CI enforces the standard (php-cs-fixer, PHPStan max, PHPUnit with recorded server conversations and a local TLS server, lowest→newest dependency matrix) on every pull request.
Credits
License
MIT License (MIT): see the LICENSE file for more details.