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:
- Přejít z Air Bank někam, kde po mně nechtějí licenci od ČNB, abych si stáhl vlastní pohyby
- Podívat se po jiné knihovně
- 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řesisset()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.
Napište komentář