As of version 8.4, the imap extension is no longer
part of PHP. Anyone who read a mailbox with it needs a replacement. Except that
the two most popular libraries are mere wrappers around that same extension, and
the third one drags in twenty-four packages including a chunk of Laravel and
Symfony. The dg/imap library speaks
IMAP on its own: under nine hundred lines of solid object-oriented code and zero
dependencies 😉
Late in 2023 I needed to download bank statements. Neither Air Bank nor Raiffeisenbank had an API, so I did what everybody does: had the statements mailed to me and parsed them out of the mailbox with a robot. Whoever runs Air Bank is going to hell for this, no other outcome is possible!
The imap extension could read a mailbox all right, working with
it just felt like 1998. imap_open() returns a resource, and when
something goes wrong the function returns false and you have to go
fetch the reason from a global error stack that you then have to clean up after
yourself.
So I wrote a wrapper. Three classes, under three hundred lines all together,
no ambition whatsoever: Mailbox, Message,
MessagePart. imap_* on the inside,
foreach on the outside. I published it on Packagist as
dg/imap, because why not, and forgot about it.
Then PHP 8.4 threw the extension out.
Wait, what?
The reasons are perfectly legitimate, so much so that you wonder what took them this long. The extension is nothing more than a thin layer over a library called c-client, written by Mark Crispin (the father of IMAP) at the University of Washington. The last release is from 2007 and has since vanished from the university's site. The unofficial GitHub mirror last moved in 2018. On top of that the library is not thread-safe, so on a ZTS build of PHP it will not compile at all. It knows nothing about XOAUTH2, and its POP3 has bugs nobody will fix, because there is nobody left to fix them.

Everybody knows this picture. What you rarely get to see is the next frame: after seventeen years, PHP pulled that little block out. (Drawing xkcd 2347, CC BY-NC 2.5.)
So: that little library of mine stood on a foundation that had ceased to exist.
What now
There were three options:
- Move from Air Bank to a bank that does not require a licence from the Czech National Bank before I can reach my own transactions.
- Look around for another library.
- Write IMAP in pure PHP.
ad 1) I switched to Fio. And then I noticed I still parse delivery notes out of that mailbox 🤦
ad 2) I went to see what is actually out there. The two most popular
libraries, ddeboer/imap and php-imap/php-imap, both
carry the line "ext-imap": "*" in composer.json. They
are wrappers. Exactly what I had.
That leaves webklex/php-imap, an honest implementation of the
protocol in PHP, and it works. Except:
composer require webklex/php-imap
Package operations: 24 installs
Twenty-four packages. Carbon, symfony/http-foundation, symfony/translation,
illuminate/pagination, illuminate/support, illuminate/collections,
doctrine/inflector, plus four different Symfony polyfills. To read a subject
line and save an attachment, I get to put a chunk of Laravel, a chunk of
Symfony and a pagination library into my vendor/.
ad 3) So I rolled up my sleeves and opened RFC 3501.
IMAP is actually a pleasant protocol
This genuinely surprised me. I was expecting a binary swamp and it turns out to be a text dialogue in which every command carries its own tag, so that it is clear which answer belongs to which:
T1 LOGIN "robot@example.com" "password"
T1 OK Success
T2 SELECT "INBOX"
* 14 EXISTS
T2 OK [READ-WRITE] INBOX selected
Of course a few tricky bits did turn up, literals for one. A server
may end a line with {4231}, which means … no, I'm not going to
bore you with the details.
The result: six classes, 842 lines, PHP 8.1 and newer, no dependency beyond
iconv, mbstring and openssl. Every sane
build of PHP has those, so they do not count (well, until it turns out that
behind them too sits a guy in a trailer who has been maintaining them since
2003, for free and without health insurance).
The API stayed the same
Anyone who used version 1.x (which was just me) has nothing to change
(hooray, I don't have to change anything!) Even the curly c-client syntax
survived, so the configuration you have (I have) in .env keeps
working:
$mailbox = new DG\Imap\Mailbox('{imap.gmail.com:993/ssl}', $username, $password);
foreach ($mailbox->getMessages() as $message) {
if (str_contains($message->getSubject(), 'Invoice')) {
file_put_contents('invoice.pdf', $message->getPart(1)->getContents());
$message->trash();
}
}
Headers for the whole mailbox arrive in a single round trip. The body is
downloaded only once you ask for it, so a message you skip by its subject never
sends its ten-megabyte attachment down the wire. And reading never sets the
Seen flag. Anyone still watching that mailbox in a mail client will
not notice somebody has been in there.
Deletion that finally admits what it does
The one place where the library behaves differently from version 1.x.
imap_delete() does not delete the message. It sets the
\Deleted flag and the actual removal happens at
EXPUNGE. What happens then, though, is the server's business. On a
plain IMAP server the message is gone. Gmail intercepts the very same
commands and applies the account's Auto-Expunge setting, which by default
merely archives it. Same protocol, same commands, opposite outcome.
This cannot be fixed, because it is not a bug in the code but an ambiguity in the protocol. All you can do is say so out loud and offer something more predictable:
$message->delete(); // the server removes it, however it sees fit
$message->trash(); // into the bin, whatever the server calls it
$message->archive(); // out of the mailbox, but not for good
$message->moveTo('Invoices'); // exactly where you say
The last three move the message, and a move behaves the same everywhere. The
destination folder is not guessed either: the server itself tells you which
folder is the bin, through the SPECIAL-USE extension:
$mailbox->getSpecialFolder('\Trash'); // '[Gmail]/Bin'
When a server advertises no such folder, trash() throws instead
of making something up. Making things up is exactly the kind of behaviour that
works on the developer's Gmail and falls apart on the customer's Exchange.
Real mail, not the RFC
The parser is deliberately tolerant, because a live mailbox is a zoo. Everything on the following list is a real message from a supplier's accounting system, and every one of them is in the test suite:
Content-Type: base64. Yes, a transfer encoding sent as the content type.MULTIPART/mixed; BOUNDARY="..."in capitals.charset = "utf-8"with spaces around the equals sign.- An empty
Dateheader. And this one is genuinely nasty:new DateTimeImmutable('')is not an error, it is now. Anisset()check is therefore not enough, and a library that misses this will quietly stamp today's date on every mail that has none.
One invalid byte inside a text part should not cost you the whole content
either. The conversion is retried with //IGNORE, and when even that
fails, the undecoded bytes come back.
How to test your own parser
The library stops at handing you the message. The invoice number, the amount and the due date are pulled out of it by code you wrote yourself. And that code wants tests.
Except what do you do with a test that has to log in to a server and hope the right invoice happens to be sitting in the mailbox?
It doesn't have to.
$message = DG\Imap\Message::fromString(file_get_contents('invoice.eml'));
Assert::same('Invoice 2026/114', $message->getSubject());
Assert::same(2, $message->countParts());
Message::fromString() builds a message out of an
.eml file, which is what any mail client saves for you with one
click. Everything works on it except deleting, which would have nothing to
reach for.
You take a handful of mails that actually arrive, drop them into your tests, and the parser is testable with no server, no network and no invented fixtures. And when a supplier quietly changes their format, you hear about it from a test rather than from your accountant.
What it does not do
There is no POP3, no STARTTLS, no OAuth 2.0, no SEARCH, no
IDLE, no flags other than \Deleted.
Fetching individual parts of a message is missing too. The body is always
downloaded whole, because parsing BODYSTRUCTURE is the ugliest
corner of the entire protocol and the saving was not worth it to me. The
boundaries are written down in docs/capabilities.md,
including what might get added one day.
The library does one thing: it reads a robot's mailbox and tidies up after itself. If you need a full mail client, this is not it and does not pretend to be.
Mark Crispin invented IMAP in 1985 at Stanford and then maintained c-client for twenty years at the University of Washington. He died in December 2012. His code served on in PHP until 2024, twelve years after him.
Leave a comment