Počínaje verzí 8.4 není součástí PHP extension imap. Kdo ji používal pro čtení pošty potřebuje náhradu. Jenže dvě nejrozšířenější knihovny jsou pouhé obálky nad touto extension a ta třetí si přitáhne dvacet čtyři balíčků včetně kusu Laravelu a Symfony. Knihovna dg/imap protokol IMAP umí: necelých devět set řádků kvalitního objektového kódu a nula závislostí 😉

Koncem roku 2023 jsem potřeboval stahovat výpisy z banky. Air Bank ani Raiffeisenbank neměly API, takže jsem to dělal tak jako každý: nechal si výpisy posílat mailem a parsoval je robotem ze schránky. Za tohle celé vedení Air Bank nutně přijde do pekla, je mi líto, ale jiná možnost prostě není.

Extension imap schránku přečíst uměla, jen se s ní pracovalo jako v roce 1998. Funkce imap_open() vrací resource a když se něco nepovede, vrací false a důvod si musíte vyzvednout z globálního zásobníku chyb.

Napsal jsem nad to obálku. Tři třídy, dohromady necelé tři stovky řádků, žádná ambice: Mailbox, Message, MessagePart. Uvnitř imap_*, venku foreach. Vydal jsem to na Packagistu jako dg/imap, protože proč ne, a přestal se tím zabývat.

Pak PHP 8.4 tu extension vyhodilo.

Cože?

Důvody jsou přitom naprosto legitimní, až se člověk diví, že to trvalo tak dlouho. Extension totiž není nic víc než tenká vrstva nad knihovnou c-client, kterou napsal Mark Crispin (otec IMAPu) na University of Washington. Poslední vydání je z roku 2007 a ze stránek univerzity mezitím zmizelo. Neoficiální mirror na GitHubu se naposledy pohnul v roce 2018. Knihovna navíc není thread-safe, takže na ZTS buildu PHP nejde přeložit vůbec. Nezná XOAUTH2 a POP3 v ní má chyby, které nikdo neopraví, protože není kdo.

Tenhle obrázek zná každý. Málokdy je ale vidět, co se stane potom: PHP ten kamínek po sedmnácti letech vytáhlo. (Kresba xkcd 2347, CC BY-NC 2.5.)

Takže: knihovnička, kterou jsem napsal, stála na základu, který přestal existovat.

Co s tím

Možnosti byly tři:

  1. Přejít z Air Bank někam, kde po mně nechtějí licenci od ČNB, abych si stáhl vlastní pohyby
  2. Podívat se po jiné knihovně
  3. Napsat IMAP v čistém PHP

ad 1) Banku jsem změnil na Fio. Jenže jsem si uvědomil, že z té schránky ještě parsuju dodací listy, takže robota se nezbavím 🤦

ad 2) Šel jsem se podívat, co vlastně existuje. Dvě nejrozšířenější knihovny, ddeboer/imap a php-imap/php-imap, mají v composer.json řádek "ext-imap": "*". Jsou to obálky. Přesně to, co jsem měl já.

Zbývá webklex/php-imap, což je poctivá implementace protokolu v PHP a funguje. Jenže:

composer require webklex/php-imap
Package operations: 24 installs 😈

Dvacet čtyři balíčků. Carbon, symfony/http-foundation, symfony/translation, illuminate/pagination, illuminate/support, illuminate/collections, doctrine/inflector a k tomu čtyři různé Symfony polyfilly. Abych si přečetl předmět zprávy a stáhl přílohu, pustím si do vendor/ kus Laravelu, kus Symfony a knihovnu na stránkování.

ad 3) Tak jsem si vyhrnul rukávy a otevřel RFC 3501.

IMAP je vlastně milý protokol

Tohle mě upřímně překvapilo. Čekal jsem bahno a on je to textový dialog, ve kterém si každý příkaz nese vlastní značku, aby bylo jasné, která odpověď patří ke kterému:

T1 LOGIN "robot@example.com" "heslo"
T1 OK Success
T2 SELECT "INBOX"
* 14 EXISTS
T2 OK [READ-WRITE] INBOX selected

Samozřejmě se objevily i nějaké zapeklitosti, třeba literály. Server smí řádek ukončit zápisem {4231}, což znamená … ale ne, nebudu vás unavovat detaily.

Výsledek: šest tříd, 842 řádků, PHP 8.1 a novější, žádná závislost kromě iconv, mbstring a openssl. Ty má každý rozumný build PHP, takže se nepočítají (teda dokud se neukáže, že i za nimi sedí chlapík v karavanu, co to od roku 2003 udržuje zadarmo a bez zdravotního pojištění).

API zůstalo stejné

Kdo používal verzi 1.0 (což jsem byl jen já), nemusí měnit vůbec nic (hurá, nemusím nic měnit!) Zůstala i ta kudrnatá syntaxe zděděná po c-clientovi, takže konfigurace, kterou máte (mám) v .env, funguje dál:

$mailbox = new DG\Imap\Mailbox('{imap.gmail.com:993/ssl}', $username, $password);

foreach ($mailbox->getMessages() as $message) {
	if (str_contains($message->getSubject(), 'Faktura')) {
		file_put_contents('faktura.pdf', $message->getPart(1)->getContents());
		$message->trash();
	}
}

Hlavičky celé schránky přijdou jediným dotazem na server. Tělo se stahuje až ve chvíli, kdy si o něj řeknete, takže když zprávu přeskočíte podle předmětu, její desetimegová příloha po drátě nikdy nepoteče. A čtení nenastavuje příznak Seen. Kdo schránku sleduje ještě v mailovém klientu, nepozná, že mu tam někdo chodí.

Mazání, které konečně přizná, co dělá

Jediné místo, kde se knihovna oproti verzi 1.0 chová jinak.

imap_delete() zprávu nesmaže. Nastaví jí příznak \Deleted a ke skutečnému odstranění dojde až při EXPUNGE. Jenže co se stane potom, je věcí serveru. Na obyčejném IMAP serveru je zpráva pryč. Gmail tytéž příkazy odchytí a řídí se nastavením Auto-Expunge, které ve výchozím stavu zprávu jen zaarchivuje. Stejný protokol, stejné příkazy, opačný výsledek.

To se nedá spravit, protože to není chyba v kódu, ale nejednoznačnost v protokolu. Dá se to jen přiznat nahlas a nabídnout něco předvídatelnějšího:

$message->delete();           // server ji odstraní, po svém
$message->trash();            // do koše, ať mu server říká jakkoliv
$message->archive();          // pryč ze schránky, ale ne nadobro
$message->moveTo('Faktury');  // přesně tam, kam řeknete

Poslední tři zprávu přesouvají a přesun se chová všude stejně. Cílová složka se přitom nehádá, server sám prostřednictvím rozšíření SPECIAL-USE řekne, která složka je koš:

$mailbox->getSpecialFolder('\Trash');   // '[Gmail]/Koš'

Když server žádnou takovou složku neinzeruje, trash() vyhodí výjimku, místo aby si vymýšlel. Vymýšlení je totiž přesně ten druh chování, který funguje na vývojářově Gmailu a rozbije se u zákazníka na Exchange.

Skutečná pošta, ne RFC

Parser je schválně tolerantní, protože ostrá schránka je džungle. Následující ukázky jsou samé reálné zprávy z účetních systémů dodavatelů:

  • Content-Type: base64. Ano, transfer encoding poslaný jako content type.
  • MULTIPART/mixed; BOUNDARY="..." verzálkami.
  • charset = "utf-8" s mezerami kolem rovnítka.
  • Prázdná hlavička Date. A tohle je opravdu záludné: new DateTimeImmutable('') není chyba, je to teď. Kontrola přes isset() tedy nestačí a knihovna, která si toho nevšimne, tiše označí každý mail bez data dnešním dnem.

Kvůli jedinému neplatnému bajtu uvnitř textové části se taky nemá ztratit celý obsah. Konverze se proto zopakuje s //IGNORE, a když nepomůže ani to, vrátí se nedekódované bajty.

Jak otestovat vlastní parser

Knihovna skončí u toho, že vám podá zprávu. Číslo faktury, částku a datum splatnosti z ní tahá kód, který jste napsali vy. A ten chce testy.

Jenže co s testem, který se kvůli tomu musí přihlásit na server a doufat, že ve schránce zrovna leží ta správná faktura?

Nemusí.

$message = DG\Imap\Message::fromString(file_get_contents('faktura.eml'));

Assert::same('Faktura 2026/114', $message->getSubject());
Assert::same(2, $message->countParts());

Message::fromString() postaví zprávu z .eml souboru, tedy z toho, co si v každém mailovém klientu uložíte jedním kliknutím. Funguje na ní všechno kromě mazání, které by nemělo kam sáhnout.

Vezmete pár mailů, které vám reálně chodí, hodíte je do testů a parser je testovatelný bez serveru, bez sítě a bez vymyšlených fixtures. A až dodavatel potichu změní formát, dozvíte se to z testu, ne od účetní.

Co to neumí

Není tam POP3, není STARTTLS, není OAuth 2.0, není SEARCH, není IDLE, nejsou příznaky kromě \Deleted.

Chybí i stahování jednotlivých částí zprávy. Tělo se vždycky stáhne celé, protože parsování BODYSTRUCTURE je nejošklivější kout celého protokolu a ta úspora mi za něj nestála. Hranice jsou sepsané v docs/capabilities.md včetně toho, co by se jednou dodělat mohlo.

Knihovna dělá jednu věc: přečte robotí schránku a uklidí po sobě. Když potřebujete plnohodnotného mailového klienta, tohle není ono a ani se o to nesnaží.


Mark Crispin vymyslel IMAP v roce 1985 na Stanfordu a c-clienta pak dvacet let udržoval na University of Washington. Zemřel v prosinci 2012. Jeho kód dosloužil v PHP až v roce 2024, dvanáct let po něm.