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:

  1. 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.
  2. Look around for another library.
  3. 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 Date header. And this one is genuinely nasty: new DateTimeImmutable('') is not an error, it is now. An isset() 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.