Construire un client de messagerie IMAP avec PHP

Publié: 2022-03-11

Les développeurs se heurtent parfois à des tâches nécessitant un accès aux boîtes aux lettres de messagerie. Dans la plupart des cas, cela se fait à l'aide du protocole Internet Message Access Protocol, ou IMAP. En tant que développeur PHP, je me suis d'abord tourné vers la bibliothèque IMAP intégrée de PHP, mais cette bibliothèque est boguée et impossible à déboguer ou à modifier. Il n'est pas non plus possible de personnaliser les commandes IMAP pour tirer pleinement parti des capacités du protocole.

Donc, aujourd'hui, nous allons créer un client de messagerie IMAP fonctionnel à partir de zéro en utilisant PHP. Nous verrons également comment utiliser les commandes spéciales de Gmail.

Nous allons implémenter IMAP dans une classe personnalisée, imap_driver . J'expliquerai chaque étape lors de la construction de la classe. Vous pouvez télécharger l'intégralité du imap_driver.php à la fin de l'article.

Établir une connexion

IMAP est un protocole basé sur la connexion et fonctionne généralement sur TCP/IP avec la sécurité SSL, donc avant de pouvoir passer des appels IMAP, nous devons ouvrir la connexion.

Nous devons connaître l'URL et le numéro de port du serveur IMAP auquel nous voulons nous connecter. Ces informations sont généralement annoncées sur le site Web ou la documentation du service. Par exemple, pour Gmail, l'URL est ssl://imap.gmail.com sur le port 993.

Puisque nous voulons savoir si l'initialisation a réussi, nous laisserons notre constructeur de classe vide, et toutes les connexions seront faites dans une méthode init() personnalisée, qui renverra false si la connexion ne peut pas être établie :

 class imap_driver { private $fp; // file pointer public $error; // error message ... public function init($host, $port) { if (!($this->fp = fsockopen($host, $port, $errno, $errstr, 15))) { $this->error = "Could not connect to host ($errno) $errstr"; return false; } if (!stream_set_timeout($this->fp, 15)) { $this->error = "Could not set timeout"; return false; } $line = fgets($this->fp); // discard the first line of the stream return true; } private function close() { fclose($this->fp); } ... }

Dans le code ci-dessus, j'ai défini un délai d'attente de 15 secondes, à la fois pour que fsockopen() établisse la connexion et pour que le flux de données lui-même réponde aux requêtes une fois qu'il est ouvert. Il est important d'avoir un délai d'attente pour chaque appel au réseau car, bien souvent, le serveur ne répond pas, et nous devons être capables de gérer un tel gel.

Je saisis également la première ligne du flux et l'ignore. Habituellement, il s'agit simplement d'un message d'accueil du serveur ou d'une confirmation qu'il est connecté. Vérifiez la documentation de votre service de messagerie particulier pour vous assurer que c'est le cas.

Maintenant, nous voulons exécuter le code ci-dessus pour voir que init() réussit :

 include("imap_driver.php"); // test for init() $imap_driver = new imap_driver(); if ($imap_driver->init('ssl://imap.gmail.com', 993) === false) { echo "init() failed: " . $imap_driver->error . "\n"; exit; }

Syntaxe IMAP de base

Maintenant que nous avons un socket actif ouvert sur notre serveur IMAP, nous pouvons commencer à envoyer des commandes IMAP. Examinons la syntaxe IMAP.

La documentation officielle se trouve dans la RFC3501 de l'IETF (Internet Engineering Task Force). Les interactions IMAP consistent généralement en ce que le client envoie des commandes et le serveur répond avec une indication de succès, ainsi que toutes les données éventuellement demandées.

La syntaxe de base des commandes est :

 line_number command arg1 arg2 ...

Le numéro de ligne, ou "tag", est un identifiant unique pour la commande, que le serveur utilise pour indiquer à quelle commande il répond s'il traite plusieurs commandes à la fois.

Voici un exemple montrant la commande LOGIN :

 00000001 LOGIN [email protected] password

La réponse du serveur peut commencer par une réponse de données « non balisée ». Par exemple, Gmail répond à une connexion réussie avec une réponse non balisée contenant des informations sur les capacités et les options du serveur, et une commande pour récupérer un e-mail recevra une réponse non balisée contenant le corps du message. Dans les deux cas, une réponse doit toujours se terminer par une ligne de réponse d'achèvement de commande « étiquetée », identifiant le numéro de ligne de la commande à laquelle la réponse s'applique, un indicateur d'état d'achèvement et des métadonnées supplémentaires sur la commande, le cas échéant :

 line_number status metadata1 metadata2 ...

Voici comment Gmail répond à la commande LOGIN :

  • Succès:
 * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS 00000001 OK [email protected] authenticated (Success)
  • Échec:
 00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)

Le statut peut être soit OK , indiquant un succès, NO , indiquant un échec, soit BAD , indiquant une commande non valide ou une mauvaise syntaxe.

Implémentation des commandes de base :

Créons une fonction pour envoyer une commande au serveur IMAP, et récupérons la réponse et la ligne de fin :

 class imap_driver { private $command_counter = "00000001"; public $last_response = array(); public $last_endline = ""; private function command($command) { $this->last_response = array(); $this->last_endline = ""; fwrite($this->fp, "$this->command_counter $command\r\n"); // send the command while ($line = fgets($this->fp)) { // fetch the response one line at a time $line = trim($line); // trim the response $line_arr = preg_split('/\s+/', $line, 0, PREG_SPLIT_NO_EMPTY); // split the response into non-empty pieces by whitespace if (count($line_arr) > 0) { $code = array_shift($line_arr); // take the first segment from the response, which will be the line number if (strtoupper($code) == $this->command_counter) { $this->last_endline = join(' ', $line_arr); // save the completion response line to parse later break; } else { $this->last_response[] = $line; // append the current line to the saved response } } else { $this->last_response[] = $line; } } $this->increment_counter(); } private function increment_counter() { $this->command_counter = sprintf('%08d', intval($this->command_counter) + 1); } ... }

La commande LOGIN

Nous pouvons maintenant écrire des fonctions pour des commandes spécifiques qui appellent notre fonction command() sous le capot. Écrivons une fonction pour la commande LOGIN :

 class imap_driver { ... public function login($login, $pwd) { $this->command("LOGIN $login $pwd"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

Maintenant, nous pouvons le tester comme ça. (Notez que vous devez avoir un compte de messagerie actif pour tester.)

 ... // test for login() if ($imap_driver->login('[email protected]', 'password') === false) { echo "login() failed: " . $imap_driver->error . "\n"; exit; }

Notez que Gmail est très strict sur la sécurité par défaut : il ne nous permettra pas d'accéder à un compte de messagerie avec IMAP si nous avons des paramètres par défaut et essayons d'y accéder depuis un pays autre que le pays du profil du compte. Mais c'est assez facile à réparer; définissez simplement des paramètres moins sécurisés dans votre compte Gmail, comme décrit ici.

La commande SELECT

Voyons maintenant comment sélectionner un dossier IMAP afin de faire quelque chose d'utile avec notre e-mail. La syntaxe est similaire à celle de LOGIN , grâce à notre méthode command() . Nous utilisons la commande SELECT à la place et spécifions le dossier.

 class imap_driver { ... public function select_folder($folder) { $this->command("SELECT $folder"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

Pour le tester, essayons de sélectionner la INBOX :

 ... // test for select_folder() if ($imap_driver->select_folder("INBOX") === false) { echo "select_folder() failed: " . $imap_driver->error . "\n"; return false; }

Implémentation des commandes avancées

Voyons comment implémenter quelques-unes des commandes les plus avancées d'IMAP.

La commande SEARCH

Une routine courante dans l'analyse des e-mails consiste à rechercher des e-mails dans une plage de dates donnée, ou à rechercher des e-mails marqués, etc. Les critères de recherche doivent être transmis à la commande SEARCH en tant qu'argument, avec un espace en tant que séparateur. Par exemple, si nous voulons récupérer tous les emails depuis le 20 novembre 2015, nous devons passer la commande suivante :

 00000005 SEARCH SINCE 20-Nov-2015

Et la réponse sera quelque chose comme ça :

 * SEARCH 881 882 00000005 OK SEARCH completed

Une documentation détaillée des termes de recherche possibles peut être trouvée ici La sortie d'une commande SEARCH est une liste d'UID d'e-mails, séparés par des espaces. Un UID est un identifiant unique d'un e-mail dans le compte de l'utilisateur, dans l'ordre chronologique, où 1 est l'e-mail le plus ancien. Pour implémenter la commande SEARCH , nous devons simplement renvoyer les UID résultants :

 class imap_driver { ... public function get_uids_by_search($criteria) { $this->command("SEARCH $criteria"); if (preg_match('~^OK~', $this->last_endline) && is_array($this->last_response) && count($this->last_response) == 1) { $splitted_response = explode(' ', $this->last_response[0]); $uids = array(); foreach ($splitted_response as $item) { if (preg_match('~^\d+$~', $item)) { $uids[] = $item; // put the returned UIDs into an array } } return $uids; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

Pour tester cette commande, nous allons récupérer les emails des trois derniers jours :

 ... // test for get_uids_by_search() $ids = $imap_driver->get_uids_by_search('SINCE ' . date('jM-Y', time() - 60 * 60 * 24 * 3)); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; exit; }

La commande FETCH avec BODY.PEEK

Une autre tâche courante consiste à obtenir des en-têtes d'e-mails sans marquer un e-mail comme SEEN . D'après le manuel IMAP, la commande pour récupérer tout ou partie d'un email est FETCH . Le premier argument indique la partie qui nous intéresse, et typiquement BODY est passé, qui renverra le message entier avec ses en-têtes, et le marquera comme SEEN . L'argument alternatif BODY.PEEK fera la même chose, sans marquer le message comme SEEN .

La syntaxe IMAP exige que notre demande spécifie également, entre crochets, la section de l'e-mail que nous voulons récupérer, qui dans cet exemple est [HEADER] . En conséquence, notre commande ressemblera à ceci :

 00000006 FETCH 2 BODY.PEEK[HEADER]

Et nous attendrons une réponse qui ressemble à ceci :

 * 2 FETCH (BODY[HEADER] {438} MIME-Version: 1.0 x-no-auto-attachment: 1 Received: by 10.170.97.214; Fri, 30 May 2014 09:13:45 -0700 (PDT) Date: Fri, 30 May 2014 09:13:45 -0700 Message-ID: <CACYy8gU+UFFukbE0Cih8kYRENMXcx1DTVhvg3TBbJ52D8OF6nQ@mail.gmail.com> Subject: The best of Gmail, wherever you are From: Gmail Team <[email protected]> To: Example Test <[email protected]> Content-Type: multipart/alternative; boundary=001a1139e3966e26ed04faa054f4 ) 00000006 OK Success

Afin de construire une fonction pour récupérer les en-têtes, nous devons pouvoir renvoyer la réponse dans une structure de hachage (paires clé/valeur) :

 class imap_driver { ... public function get_headers_from_uid($uid) { $this->command("FETCH $uid BODY.PEEK[HEADER]"); if (preg_match('~^OK~', $this->last_endline)) { array_shift($this->last_response); // skip the first line $headers = array(); $prev_match = ''; foreach ($this->last_response as $item) { if (preg_match('~^([az][a-z0-9-_]+):~is', $item, $match)) { $header_name = strtolower($match[1]); $prev_match = $header_name; $headers[$header_name] = trim(substr($item, strlen($header_name) + 1)); } else { $headers[$prev_match] .= " " . $item; } } return $headers; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

Et pour tester ce code on précise juste l'UID du message qui nous intéresse :

 ... // test for get_headers_by_uid if (($headers = $imap_driver->get_headers_from_uid(2)) === false) { echo "get_headers_by_uid() failed: " . $imap_driver->error . "\n"; return false; }

Extensions Gmail IMAP

Gmail fournit une liste de commandes spéciales qui peuvent nous faciliter la vie. La liste des commandes d'extension IMAP de Gmail est disponible ici. Passons en revue une commande qui, à mon avis, est la plus importante : X-GM-RAW . Il nous permet d'utiliser la syntaxe de recherche Gmail avec IMAP. Par exemple, nous pouvons rechercher des e-mails appartenant aux catégories Principal, Social, Promotions, Mises à jour ou Forums.

Fonctionnellement, X-GM-RAW est une extension de la commande SEARCH , nous pouvons donc réutiliser le code que nous avons ci-dessus pour la commande SEARCH . Il suffit d'ajouter le mot-clé X-GM-RAW et les critères :

 ... // test for gmail extended search functionality $ids = $imap_driver->get_uids_by_search(' X-GM-RAW "category:primary"'); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; return false; }

Le code ci-dessus renverra tous les UID répertoriés dans la catégorie "Primaire".

Remarque : Depuis décembre 2015, Gmail confond souvent la catégorie "Principale" avec la catégorie "Mises à jour" sur certains comptes. Il s'agit d'un bogue Gmail qui n'a pas encore été corrigé.

Conclusion

Vous avez un email. Maintenant quoi? Découvrez comment créer un client de messagerie IMAP personnalisé en PHP et vérifiez le courrier selon vos conditions.
Tweeter

Dans l'ensemble, l'approche de socket personnalisée offre plus de liberté au développeur. Il permet d'implémenter toutes les commandes dans IMAP RFC3501. Cela vous donnera également un meilleur contrôle sur votre code, puisque vous n'avez pas à vous demander ce qui se passe « dans les coulisses ».

La classe imap_driver complète que nous avons implémentée dans cet article peut être trouvée ici. Il peut être utilisé tel quel et il ne faudra que quelques minutes à un développeur pour écrire une nouvelle fonction ou requête à son serveur IMAP. J'ai également inclus une fonctionnalité de débogage dans la classe pour une sortie détaillée.