16. Fonctions réseau
Nous abordons maintenant les fonctions réseau de PHP qui nous permettent de faire de la programmation TCP / IP (Transmission Control Protocol / Internet Protocol). Les scripts de ce chapitre sont dans le dossier [scripts-console/inet] du code du cours :
| php8-oct-2026
└── scripts-console
└── inet
├── http
├── imap
├── ip
├── mailpit
├── smtp
└── utilitaires
|
16.1. Les bases de la programmation internet
16.1.1. Généralités
Considérons la communication entre deux machines distantes A et B :

Lorsqu'une application AppA d'une machine A veut communiquer avec une application AppB d'une machine B de l'Internet, elle doit connaître plusieurs choses :
- l'adresse IP (Internet Protocol) ou le nom de la machine B ;
- le numéro du port avec lequel travaille l'application AppB. En effet, la machine B peut supporter de nombreuses applications qui travaillent sur l'Internet. Lorsqu'elle reçoit des informations provenant du réseau, elle doit savoir à quelle application sont destinées ces informations. Les applications de la machine B ont accès au réseau via des guichets appelés également des ports de communication. Cette information est contenue dans le paquet reçu par la machine B afin qu'il soit délivré à la bonne application ;
- les protocoles de communication compris par la machine B. Dans notre étude, nous utiliserons uniquement les protocoles TCP / IP ;
- le protocole de dialogue accepté par l'application AppB. En effet, les machines A et B vont se « parler ». Ce qu'elles vont dire va être encapsulé dans les protocoles TCP / IP. Néanmoins, lorsqu'au bout de la chaîne, l'application AppB va recevoir l'information envoyée par l'application AppA, il faut qu'elle soit capable de l'interpréter. Ceci est analogue à la situation où deux personnes A et B communiquent par téléphone : leur dialogue est transporté par le téléphone. La parole va être codée sous forme de signaux par le téléphone A, transportée par des lignes téléphoniques, arriver au téléphone B pour y être décodée. La personne B entend alors des paroles. C'est là qu'intervient la notion de protocole de dialogue : si A parle français et que B ne comprend pas cette langue, A et B ne pourront dialoguer utilement .
Aussi les deux applications communicantes doivent-elles être d'accord sur le type de dialogue qu'elles vont adopter. Par exemple, le dialogue avec un service FTP n'est pas le même qu'avec un service POP : ces deux services n'acceptent pas les mêmes commandes. Ils ont un protocole de dialogue différent.
16.1.2. Les caractéristiques du protocole TCP
Nous n'étudierons ici que des communications réseau utilisant le protocole de transport TCP dont voici les principales caractéristiques :
- le processus qui souhaite émettre établit tout d'abord une connexion avec le processus destinataire des informations qu'il va émettre. Cette connexion se fait entre un port de la machine émettrice et un port de la machine réceptrice. Il y a entre les deux ports un chemin virtuel qui est ainsi créé et qui sera réservé aux deux seuls processus ayant réalisé la connexion ;
- tous les paquets émis par le processus source suivent ce chemin virtuel et arrivent dans l'ordre où ils ont été émis ;
- l'information émise a un aspect continu. Le processus émetteur envoie des informations à son rythme. Celles-ci ne sont pas nécessairement envoyées tout de suite : le protocole TCP attend d'en avoir assez pour les envoyer. Elles sont stockées dans une structure appelée segment TCP. Ce segment une fois rempli sera transmis à la couche IP où il sera encapsulé dans un paquet IP ;
- chaque segment envoyé par le protocole TCP est numéroté. Le protocole TCP destinataire vérifie qu'il reçoit bien les segments en séquence. Pour chaque segment correctement reçu, il envoie un accusé de réception à l'expéditeur ;
- lorsque ce dernier le reçoit, il l'indique au processus émetteur. Celui-ci peut donc savoir qu'un segment est arrivé à bon port ;
- si au bout d'un certain temps, le protocole TCP ayant émis un segment ne reçoit pas d'accusé de réception, il retransmet le segment en question, garantissant ainsi la qualité du service d'acheminement de l'information ;
- le circuit virtuel établi entre les deux processus qui communiquent est full-duplex : cela signifie que l'information peut transiter dans les deux sens. Ainsi le processus destination peut envoyer des accusés de réception alors même que le processus source continue d'envoyer des informations. Cela permet par exemple au protocole TCP source d'envoyer plusieurs segments sans attendre d'accusé de réception. S'il réalise au bout d'un certain temps qu'il n'a pas reçu l'accusé de réception d'un certain segment n° n, il reprendra l'émission des segments à ce point .
16.1.3. La relation client-serveur
Souvent, la communication sur Internet est dissymétrique : la machine A initie une connexion pour demander un service à la machine B. Elle précise qu'elle veut ouvrir une connexion avec le service SB1 de la machine B. Celle-ci accepte ou refuse. Si elle accepte, la machine A peut envoyer ses demandes au service SB1. Celles-ci doivent se conformer au protocole de dialogue compris par le service SB1. Un dialogue demande-réponse s'instaure ainsi entre la machine A qu'on appelle machine cliente et la machine B qu'on appelle machine serveur. L'un des deux partenaires fermera la connexion.
16.1.4. Architecture d'un client
L'architecture d'un programme réseau demandant les services d'une application serveur sera la suivante :
| ouvrir la connexion avec le service SB1 de la machine B
si réussite alors
tant que ce n'est pas fini
préparer une demande
l'émettre vers la machine B
attendre et récupérer la réponse
la traiter
fin tant que
finsi
fermer la connexion
|
16.1.5. Architecture d'un serveur
L'architecture d'un programme offrant des services sera la suivante :
| ouvrir le service sur la machine locale
tant que le service est ouvert
se mettre à l'écoute des demandes de connexion sur un port dit port d'écoute
lorsqu'il y a une demande, la faire traiter par une autre tâche sur un autre port dit port de service
fin tant que
|
Le programme serveur traite différemment la demande de connexion initiale d'un client et ses demandes ultérieures visant à obtenir un service. Le programme n'assure pas le service lui-même. S'il le faisait, pendant la durée du service il ne serait plus à l'écoute des demandes de connexion et des clients ne seraient alors pas servis. Il procède donc autrement : dès qu'une demande de connexion est reçue sur le port d'écoute puis acceptée, le serveur crée une tâche chargée de rendre le service demandé par le client. Ce service est rendu sur un autre port de la machine serveur appelé port de service. On peut ainsi servir plusieurs clients en même temps.
Une tâche de service aura la structure suivante :
| tant que le service n'a pas été rendu totalement
attendre une demande sur le port de service
lorsqu'il y en a une, élaborer la réponse
transmettre la réponse via le port de service
fin tant que
libérer le port de service
|
Une autre façon de servir plusieurs clients à la fois, sans créer de tâches, est de surveiller simultanément le port d'écoute et les ports de service avec une fonction qui attend que l'un d'eux ait quelque chose à dire. C'est ce que fera le serveur TCP que nous allons écrire, avec la fonction [stream_select] de PHP.
16.2. Découvrir les protocoles de communication de l'internet
16.2.1. Introduction
Lorsqu'un client s'est connecté à un serveur, s'établit ensuite un dialogue entre eux. La nature de celui-ci forme ce qu'on appelle le protocole de communication du serveur. Parmi les protocoles les plus courants de l'internet, on trouve les suivants :
- HTTP : HyperText Transfer Protocol - le protocole de dialogue avec un serveur web (serveur HTTP) ;
- SMTP : Simple Mail Transfer Protocol - le protocole de dialogue avec un serveur d'envoi de courriers électroniques (serveur SMTP) ;
- POP : Post Office Protocol - le protocole de dialogue avec un serveur de stockage du courrier électronique (serveur POP). Il s'agit là de récupérer les courriers électroniques reçus et non d'en envoyer ;
- IMAP : Internet Message Access Protocol - le protocole de dialogue avec un serveur de stockage du courrier électronique (serveur IMAP). Ce protocole a remplacé progressivement le protocole POP plus ancien ;
- FTP : File Transfer Protocol - le protocole de dialogue avec un serveur de stockage de fichiers (serveur FTP) .
Tous ces protocoles ont la particularité d'être des protocoles à lignes de texte : le client et le serveur s'échangent des lignes de texte. Si on a un client capable de :
- créer une connexion avec un serveur TCP ;
- afficher à la console les lignes de texte que le serveur lui envoie ;
- envoyer au serveur les lignes de texte qu'un utilisateur saisirait au clavier ;
alors on est capable de dialoguer avec un serveur TCP ayant un protocole à lignes de texte pour peu qu'on connaisse les règles de ce protocole.
16.2.2. Utilitaires TCP
Le cours PHP 7 fournissait deux utilitaires Windows, [RawTcpClient] et [RawTcpServer], écrits en C#. Nous les remplaçons ici par deux scripts PHP, que nous allons lire : c'est l'occasion de découvrir les fonctions réseau de PHP. Ils sont dans le dossier [inet/utilitaires] :
| utilitaires
├── clavier.php
├── tcp-client.php
└── tcp-server.php
|
- [tcp-client.php] permet de se connecter sur le port P d'un serveur S ;
- [tcp-server.php] permet de créer un serveur qui attend des clients sur un port P ;
- [clavier.php] contient deux fonctions utilisées par les deux scripts précédents .
Signalons qu'il existe aussi des outils tout faits pour ce genre de test. Sous Windows, la commande PowerShell [Test-NetConnection localhost -Port 80] vérifie simplement qu'un service répond sur un port. L'outil [curl], que nous rencontrerons avec le protocole HTTP, sait dialoguer avec de nombreux serveurs, et son option [-v] (verbose) affiche les échanges client / serveur. Nos deux scripts, eux, nous laissent taper nous-mêmes les commandes du protocole.
16.2.2.1. Le serveur [tcp-server.php]
Le serveur TCP [tcp-server.php] s'appelle avec la syntaxe [php tcp-server.php port] pour créer un service TCP sur le port [port] de la machine locale (l'ordinateur sur lequel vous travaillez) :
- le serveur peut servir plusieurs clients simultanément ;
- le serveur exécute les commandes tapées par l'utilisateur au clavier. Celles-ci sont les suivantes :
- list : liste les clients actuellement connectés au serveur. Ceux-ci sont affichés sous la forme [id=x - nom=y]. Le champ [id] sert à identifier les clients ;
- send x [texte] : envoie texte au client n° x (id=x). Les crochets [] ne sont pas envoyés. Ils sont nécessaires dans la commande. Ils servent à délimiter visuellement le texte envoyé au client ;
- close x : ferme la connexion avec le client n° x ;
- quit : ferme toutes les connexions et arrête le service ;
- les lignes envoyées par un client au serveur sont affichées sur la console ;
- l'ensemble des échanges avec un client est enregistré dans un fichier texte [logs/machine-port.txt] où [machine-port] identifie le client (son adresse IP et le port qu'il utilise) .
Le code du serveur est le suivant :
| <?php
declare(strict_types=1);
// serveur TCP générique
// syntaxe : php tcp-server.php port
// - attend des clients sur le port [port] de la machine locale et peut en servir plusieurs à la fois ;
// - affiche les lignes de texte envoyées par les clients ;
// - exécute les commandes tapées au clavier :
// list : liste les clients connectés ;
// send id [texte] : envoie [texte] (sans les crochets) au client n° id ;
// close id : ferme la connexion avec le client n° id ;
// quit : ferme toutes les connexions et arrête le serveur ;
// - enregistre les échanges avec chaque client dans le fichier [logs/machine-port.txt]
// où [machine-port] identifie le client
require_once __DIR__ . "/clavier.php";
// analyse des arguments de la ligne de commande
if (count($argv) !== 2 || !ctype_digit($argv[1])) {
print "Syntaxe : php tcp-server.php port\n";
exit(1);
}
$port = $argv[1];
// ouverture du service sur toutes les interfaces réseau de la machine locale
$serveur = @stream_socket_server("tcp://0.0.0.0:$port", $numErreur, $erreur);
if ($serveur === false) {
print "Impossible d'ouvrir le service sur le port [$port] : $erreur\n";
exit(2);
}
print "Serveur à l'écoute sur le port [$port]\n";
print "Commandes : list, send id [texte], close id, quit\n";
if (!is_dir(__DIR__ . "/logs")) {
mkdir(__DIR__ . "/logs");
}
// les clients connectés : id => [socket, nom, fichier de logs, tampon]
$clients = [];
$prochainId = 1;
// le clavier est lu par un processus fils
[$lecteur, $clavier] = lancerLecteurClavier();
$écho = !stream_isatty(STDIN);
$tamponClavier = "";
// boucle de service
$fini = false;
while (!$fini) {
// on surveille : le port d'écoute, les clients et le clavier
$lectures = [$serveur, $clavier, ...array_column($clients, "socket")];
$écritures = $exceptions = null;
stream_select($lectures, $écritures, $exceptions, null);
foreach ($lectures as $flux) {
if ($flux === $serveur) {
// un nouveau client se connecte
$socket = stream_socket_accept($serveur, 0, $nom);
if ($socket !== false) {
$id = $prochainId++;
$log = fopen(__DIR__ . "/logs/" . str_replace(":", "-", $nom) . ".txt", "w");
$clients[$id] = ["socket" => $socket, "nom" => $nom, "log" => $log, "tampon" => ""];
print "Client n° $id [$nom] connecté\n";
}
} elseif ($flux === $clavier) {
// une commande a été tapée au clavier
$données = fread($clavier, 8192);
if ($données === false || $données === "") {
// fin de l'entrée standard : on arrête le serveur
$fini = true;
break;
}
foreach (extraireLignes($tamponClavier, $données) as $commande) {
if ($écho) {
print "$commande\n";
}
if (exécuter($commande, $clients)) {
$fini = true;
break;
}
}
} else {
// un client a parlé
$id = array_find_key($clients, fn(array $client): bool => $client["socket"] === $flux);
if ($id === null) {
// client fermé entre-temps par la commande [close]
continue;
}
$données = fread($flux, 8192);
if ($données === false || $données === "") {
print "Le client n° $id a fermé la connexion\n";
fermer($clients, $id);
continue;
}
foreach (extraireLignes($clients[$id]["tampon"], $données) as $ligne) {
print "<-- client n° $id [$ligne]\n";
fwrite($clients[$id]["log"], "<-- [$ligne]\n");
}
}
}
}
// arrêt du service
foreach (array_keys($clients) as $id) {
fermer($clients, $id);
}
fclose($serveur);
proc_terminate($lecteur);
print "Serveur arrêté\n";
// --------------------------------------------------------------------------
// exécute une commande tapée au clavier - rend true si c'est la commande [quit]
function exécuter(string $commande, array &$clients): bool
{
// send id [texte]
if (preg_match('/^send\s+(\d+)\s+\[(.*)\]$/', $commande, $champs)) {
$id = (int) $champs[1];
if (!isset($clients[$id])) {
print "Client n° $id inconnu\n";
} else {
fwrite($clients[$id]["socket"], "$champs[2]\r\n");
fwrite($clients[$id]["log"], "--> [$champs[2]]\n");
}
return false;
}
// les autres commandes
$mots = preg_split('/\s+/', trim($commande));
switch ($mots[0]) {
case "list":
foreach ($clients as $id => $client) {
print "id=$id - nom={$client["nom"]}\n";
}
return false;
case "close":
$id = (int) ($mots[1] ?? 0);
if (!isset($clients[$id])) {
print "Client n° $id inconnu\n";
} else {
fermer($clients, $id);
print "Connexion avec le client n° $id fermée\n";
}
return false;
case "quit":
return true;
default:
print "Commande [$commande] inconnue. Commandes : list, send id [texte], close id, quit\n";
return false;
}
}
// --------------------------------------------------------------------------
// ferme la connexion avec le client n° [$id]
function fermer(array &$clients, int $id): void
{
fclose($clients[$id]["socket"]);
fclose($clients[$id]["log"]);
unset($clients[$id]);
}
|
Commentaires :
- ligne 17 : on charge le fichier [clavier.php] que nous verrons plus loin ;
- ligne 20 : le tableau [$argv] contient les arguments de la ligne de commande. [$argv[0]] est le nom du script, [$argv[1]] le premier argument, ici le n° du port. La fonction [ctype_digit] vérifie que ce dernier n'est fait que de chiffres ;
- ligne 27 : la fonction [stream_socket_server] ouvre un service TCP. Son premier paramètre est une adresse de la forme [tcp://interface:port]. L'interface [0.0.0.0] désigne toutes les interfaces réseau de la machine : le serveur sera joignable aussi bien par [localhost] que par l'adresse IP de la machine sur le réseau local. En cas d'échec (port déjà utilisé par exemple), la fonction rend [false] et met un n° d'erreur et un message d'erreur dans les deux variables passées en 2e et 3e paramètres. L'opérateur [@] empêche PHP d'afficher son propre avertissement : nous affichons notre message ;
- ligne 39 : le dictionnaire des clients connectés. Chaque client est décrit par sa socket (la connexion qui le relie au serveur), son nom [adresse:port], son fichier de logs et un tampon dont nous verrons le rôle ;
- ligne 42 : le clavier est lu par un processus fils. Nous verrons pourquoi avec le fichier [clavier.php]. [$clavier] est un flux dans lequel arrivent les lignes tapées au clavier ;
- ligne 43 : la fonction [stream_isatty] indique si l'entrée standard est le clavier (un terminal) ou non. Si l'entrée standard a été redirigée vers un fichier ([php tcp-server.php 100 < commandes.txt]), les commandes lues ne s'affichent pas à l'écran. Le serveur les affichera alors lui-même ;
- lignes 48-52 : la boucle de service. À chaque tour, le serveur attend qu'un des flux qu'il surveille ait quelque chose à lui dire : le port d'écoute (un nouveau client se connecte), le clavier (une commande a été tapée) ou l'un des clients (il a envoyé du texte). L'opérateur [...] décompose le tableau des sockets des clients (obtenu avec [array_column]) dans le tableau [$lectures] ;
- ligne 52 : la fonction [stream_select] attend que l'un des flux du tableau [$lectures] soit lisible. Le dernier paramètre [null] signifie : attendre sans limite de temps. Au retour, la fonction a enlevé du tableau [$lectures] les flux qui n'ont rien à lire. Les 2e et 3e paramètres concernent les flux sur lesquels on veut écrire et les événements exceptionnels : ils ne nous intéressent pas. Comme ils sont passés par référence, il faut leur passer des variables, ici [$écritures] et [$exceptions] qui valent [null] ;
- lignes 54-60 : un nouveau client se connecte. La fonction [stream_socket_accept] accepte la connexion. Elle rend la socket de service qui relie le serveur à ce client et met dans [$nom] l'identité du client sous la forme [adresse:port]. Le timeout 0 signifie qu'on n'attend pas : [stream_select] nous a dit qu'un client attendait ;
- lignes 63-75 : une commande a été tapée au clavier. La fonction [fread] lit ce qui est disponible (au plus 8192 octets). Une chaîne vide signifie que le flux a été fermé. La fonction [extraireLignes] découpe ce qui a été lu en lignes complètes ;
- lignes 81-95 : un client a envoyé du texte. La fonction [array_find_key] (PHP 8.4) rend la clé du premier élément du tableau qui vérifie la condition exprimée par la fonction fléchée [fn] passée en 2e paramètre : on retrouve ainsi le n° du client à partir de sa socket. Si [fread] rend une chaîne vide, le client a fermé la connexion. Sinon chaque ligne reçue est affichée et enregistrée dans le fichier de logs du client ;
- lignes 110-141 : la fonction [exécuter] exécute une commande tapée au clavier. La commande [send id [texte]] est reconnue par une expression régulière. Le texte est envoyé au client suivi de la séquence \r\n : les protocoles de l'internet terminent les lignes de texte par les deux caractères CR (\r) et LF (\n) ;
- ligne 150 : la fonction [fermer] ferme la connexion avec un client et l'enlève du dictionnaire [$clients], passé par référence ([&]) .
16.2.2.2. Le fichier [clavier.php]
Le fichier [clavier.php] est le suivant :
| <?php
declare(strict_types=1);
// outils communs à [tcp-client.php] et [tcp-server.php]
// --------------------------------------------------------------------------
// lance un processus fils qui lit le clavier et nous renvoie chaque ligne tapée
// par une socket. Pourquoi ? Nos programmes doivent surveiller en même temps le
// réseau et le clavier avec la fonction [stream_select]. Or sous Windows, [stream_select]
// ne sait surveiller que des sockets, pas le clavier (STDIN).
// Le descripteur ['socket'] de [proc_open] (PHP 8.0) relie le processus fils au
// processus père par une socket, que [stream_select] sait surveiller sur tous les OS.
// rend le processus fils et la socket qui délivre les lignes tapées au clavier
function lancerLecteurClavier(): array
{
$processus = proc_open(
// le processus fils : un script PHP d'une ligne qui recopie son entrée sur sa sortie
[PHP_BINARY, "-r", 'while (($ligne = fgets(STDIN)) !== false) { echo $ligne; }'],
// son entrée est notre clavier, sa sortie est une socket
[0 => STDIN, 1 => ["socket"], 2 => STDERR],
$tubes
);
if ($processus === false) {
throw new RuntimeException("Impossible de lancer le lecteur du clavier");
}
return [$processus, $tubes[1]];
}
// --------------------------------------------------------------------------
// ajoute les octets [$données] au tampon [$tampon] et en extrait les lignes complètes,
// rendues sans leur marque de fin de ligne (\n ou \r\n) ;
// la fin du tampon (une ligne incomplète) reste dans le tampon
function extraireLignes(string &$tampon, string $données): array
{
$tampon .= $données;
$lignes = explode("\n", $tampon);
// le dernier élément est la ligne en cours, pas encore terminée
$tampon = array_pop($lignes);
return array_map(fn(string $ligne): string => rtrim($ligne, "\r"), $lignes);
}
|
Commentaires :
- ligne 15 : nos deux scripts doivent surveiller en même temps le réseau et le clavier. Sous Linux ou macOS, on pourrait passer le flux [STDIN] (le clavier) à [stream_select]. Sous Windows, ce n'est pas possible : [stream_select] ne sait y surveiller que des sockets. La parade consiste à faire lire le clavier par un second processus qui nous renvoie les lignes tapées par une socket ;
- lignes 17-22 : la fonction [proc_open] lance un processus fils. Son 1er paramètre est la commande à exécuter, sous forme de tableau : l'interpréteur PHP courant ([PHP_BINARY]) avec l'option [-r] qui exécute le code PHP passé en argument. Ce code recopie chaque ligne lue sur son entrée vers sa sortie. Le 2e paramètre décrit les flux du processus fils : son entrée (0) est notre entrée standard (le clavier), sa sortie (1) est une socket créée par PHP (descripteur [['socket']], disponible depuis PHP 8.0), ses erreurs (2) vont sur notre sortie d'erreur. Le 3e paramètre reçoit les flux créés : [$tubes[1]] est notre extrémité de la socket ;
- ligne 34 : quand [stream_select] signale qu'un flux est lisible, [fread] rend ce qui est arrivé : cela peut être une ligne, plusieurs lignes ou une partie de ligne. La fonction [extraireLignes] ajoute ces octets au tampon [$tampon] (passé par référence), en extrait les lignes complètes et garde dans le tampon la ligne non terminée. On notera la fonction fléchée [fn(string $ligne): string => rtrim($ligne, "\r")] qui enlève le \r final des lignes terminées par \r\n .
On aurait pu lire les lignes avec la fonction [fgets]. Mais [fgets] lit les données par blocs et garde dans un tampon interne à PHP les lignes qu'elle n'a pas encore rendues. [stream_select], qui ne regarde que le réseau, ne les verrait pas. Lire avec [fread] et découper nous-mêmes les lignes évite ce piège.
16.2.2.3. Le client [tcp-client.php]
Le client TCP [tcp-client.php] s'appelle avec la syntaxe [php tcp-client.php [--quit commande] [--tls] serveur port] pour se connecter au port [port] du serveur [serveur] :
- les lignes tapées par l'utilisateur au clavier sont envoyées au serveur ;
- les lignes envoyées par le serveur sont affichées sur la console dès qu'elles arrivent ;
- l'ensemble des échanges est enregistré dans un fichier texte [logs/serveur-port.txt] ;
- la commande [quit] arrête le client. L'option [--quit] permet de choisir une autre commande ;
- l'option [--tls] chiffre la connexion. Nous en aurons besoin pour dialoguer avec des serveurs sécurisés .
Son code est le suivant :
| <?php
declare(strict_types=1);
// client TCP générique
// syntaxe : php tcp-client.php [--quit commande] [--tls] serveur port
// - se connecte au port [port] de la machine [serveur] ;
// - affiche les lignes de texte envoyées par le serveur dès qu'elles arrivent ;
// - envoie au serveur les lignes de texte tapées au clavier ;
// - enregistre tous les échanges dans le fichier [logs/serveur-port.txt]
// options :
// --quit commande : la commande qui arrête le client (par défaut [quit])
// --tls : la connexion est chiffrée (TLS) dès son ouverture
require_once __DIR__ . "/clavier.php";
// analyse des arguments de la ligne de commande
$quit = "quit";
$tls = false;
$arguments = [];
for ($i = 1; $i < count($argv); $i++) {
match ($argv[$i]) {
"--quit" => $quit = $argv[++$i] ?? $quit,
"--tls" => $tls = true,
default => $arguments[] = $argv[$i],
};
}
if (count($arguments) !== 2 || !ctype_digit($arguments[1])) {
print "Syntaxe : php tcp-client.php [--quit commande] [--tls] serveur port\n";
exit(1);
}
[$serveur, $port] = $arguments;
// ouverture de la connexion avec le serveur
// avec --tls, on ne vérifie pas le certificat du serveur : c'est un outil de découverte
$contexte = stream_context_create(["ssl" => ["verify_peer" => false, "verify_peer_name" => false]]);
$transport = $tls ? "tls" : "tcp";
$connexion = @stream_socket_client("$transport://$serveur:$port", $numErreur, $erreur, 10,
STREAM_CLIENT_CONNECT, $contexte);
if ($connexion === false) {
print "Échec de la connexion au serveur [$serveur:$port] : $erreur\n";
exit(2);
}
$client = stream_socket_get_name($connexion, false);
print "Client [$client] connecté au serveur [$serveur:$port]\n";
print "Tapez vos commandes ($quit pour arrêter) :\n";
// fichier de logs
if (!is_dir(__DIR__ . "/logs")) {
mkdir(__DIR__ . "/logs");
}
$log = fopen(__DIR__ . "/logs/$serveur-$port.txt", "w");
// le clavier est lu par un processus fils
[$lecteur, $clavier] = lancerLecteurClavier();
// si l'entrée standard est un fichier (php tcp-client.php ... < commandes.txt),
// les commandes lues ne s'affichent pas à l'écran : on les affichera nous-mêmes
$écho = !stream_isatty(STDIN);
// tampons des lignes incomplètes reçues du serveur et du clavier
$tamponServeur = "";
$tamponClavier = "";
// boucle de dialogue avec le serveur
$fini = false;
while (!$fini) {
// on attend que le serveur ou le clavier ait quelque chose à nous dire
$lectures = $clavier !== null ? [$connexion, $clavier] : [$connexion];
$écritures = $exceptions = null;
// sans clavier (fin de l'entrée standard), on n'attend pas le serveur plus de 2 secondes
if (stream_select($lectures, $écritures, $exceptions, $clavier !== null ? null : 2) === 0) {
break;
}
// le serveur a parlé ?
if (in_array($connexion, $lectures, true)) {
$données = fread($connexion, 8192);
if ($données === false || $données === "") {
// le serveur a fermé la connexion
if ($tamponServeur !== "") {
afficher("<-- [$tamponServeur]", $log);
}
print "Perte de la connexion avec le serveur...\n";
break;
}
foreach (extraireLignes($tamponServeur, $données) as $ligne) {
afficher("<-- [$ligne]", $log);
}
}
// une ligne a été tapée au clavier ?
if ($clavier !== null && in_array($clavier, $lectures, true)) {
$données = fread($clavier, 8192);
if ($données === false || $données === "") {
// fin de l'entrée standard
$clavier = null;
continue;
}
foreach (extraireLignes($tamponClavier, $données) as $commande) {
if ($écho) {
print "$commande\n";
}
// fin du client ?
if ($commande === $quit) {
$fini = true;
break;
}
// on envoie la commande au serveur : les protocoles de l'internet
// terminent les lignes par la séquence \r\n
fwrite($connexion, "$commande\r\n");
fwrite($log, "--> [$commande]\n");
}
}
}
// fermeture de la connexion, du fichier de logs et du lecteur du clavier
fclose($connexion);
fclose($log);
proc_terminate($lecteur);
print "Fin du client\n";
// --------------------------------------------------------------------------
// affiche une ligne reçue et l'enregistre dans le fichier de logs
function afficher(string $ligne, $log): void
{
print "$ligne\n";
fwrite($log, "$ligne\n");
}
|
Commentaires :
- lignes 21-26 : l'analyse des arguments. L'expression [match] (PHP 8.0) compare son argument aux valeurs de ses branches avec l'opérateur [===] et évalue la branche correspondante. Ici, chaque branche est une affectation. [$argv[++$i] ?? $quit] rend l'argument qui suit [--quit] s'il existe, et [$quit] sinon ;
- ligne 32 : la déstructuration d'un tableau : [$serveur] reçoit [$arguments[0]] et [$port] reçoit [$arguments[1]] ;
- lignes 36-39 : la fonction [stream_socket_client] ouvre une connexion avec le service [tcp://serveur:port]. Avec l'option [--tls], l'adresse devient [tls://serveur:port] : PHP chiffre alors la connexion avec le protocole TLS (Transport Layer Security). Le contexte créé par [stream_context_create] précise ici que le certificat du serveur ne doit pas être vérifié : pour un outil de découverte, cela évite des problèmes avec les serveurs de test qui ont des certificats autosignés. Le 4e paramètre est le temps maximal (en secondes) accordé à l'ouverture de la connexion ;
- ligne 44 : la fonction [stream_socket_get_name] rend l'adresse locale (false) ou distante (true) d'une connexion, sous la forme [adresse:port] ;
- lignes 65-70 : la boucle de dialogue. Elle a la même structure que celle du serveur : [stream_select] attend que le serveur ou le clavier ait quelque chose à dire. Lorsque l'entrée standard est terminée (cas d'une entrée redirigée vers un fichier), le client continue d'afficher ce qu'envoie le serveur, mais ne l'attend pas plus de 2 secondes ;
- lignes 73-85 : les lignes envoyées par le serveur sont affichées, encadrées par des crochets, et enregistrées dans le fichier de logs. Si le serveur ferme la connexion, la boucle s'arrête ;
- lignes 88-108 : chaque ligne tapée au clavier est envoyée au serveur, suivie de la séquence \r\n, sauf la commande d'arrêt du client ;
- ligne 115 : à la fin, on arrête le processus fils qui lit le clavier .
16.2.2.4. Un exemple
Voyons un exemple. On ouvre deux terminaux (dans VS Code, le bouton [+] de la vue [Terminal] en ouvre un nouveau) et on se positionne dans chacun d'eux sur le dossier [inet/utilitaires]. Dans le premier terminal, on lance le serveur sur le port 100. Dans le second, on lance le client en lui demandant de se connecter au port 100 de la machine locale [localhost], puis on tape une ligne de texte. On revient au serveur, on répond au client n° 1, on demande la liste des clients, on ferme la connexion avec le client n° 1 et on arrête le serveur.
Voici ce qu'affiche le serveur (les commandes tapées au clavier sont les lignes [send 1 ...], [list], [close 1] et [quit]) :
| > php tcp-server.php 100
Serveur à l'écoute sur le port [100]
Commandes : list, send id [texte], close id, quit
Client n° 1 [127.0.0.1:47966] connecté
<-- client n° 1 [hello from client]
send 1 [hello from server]
list
id=1 - nom=127.0.0.1:47966
close 1
Connexion avec le client n° 1 fermée
quit
Serveur arrêté
|
- [Client n° 1 [127.0.0.1:47966] connecté] : un client a été détecté. Le serveur lui a donné le n° 1. Le client utilise le port 47966 de la machine d'adresse IP 127.0.0.1. Cette adresse est celle de [localhost], la machine locale ;
- [<-- client n° 1 [hello from client]] : entre crochets, la ligne reçue du client ;
- [send 1 [hello from server]] : la réponse envoyée au client n° 1. Seul le texte entre les crochets est envoyé, pas les crochets eux-mêmes ;
- [list] : la liste des clients et sa réponse ;
- [close 1] puis [quit] : on ferme la connexion avec le client n° 1, puis on arrête le serveur .
Et voici ce qu'affiche le client :
| > php tcp-client.php localhost 100
Client [127.0.0.1:47966] connecté au serveur [localhost:100]
Tapez vos commandes (quit pour arrêter) :
hello from client
<-- [hello from server]
Perte de la connexion avec le serveur...
Fin du client
|
- [hello from client] : la ligne tapée au clavier ;
- [<-- [hello from server]] : la réponse reçue du serveur. Le texte reçu est celui entre crochets ;
- [Perte de la connexion avec le serveur...] : le client a détecté la fermeture de la connexion par le serveur .
Deux fichiers de logs ont été créés dans le dossier [utilitaires/logs], un par le serveur, un autre par le client. Le fichier du serveur porte le nom du client [127.0.0.1-47966.txt] :
| <-- [hello from client]
--> [hello from server]
|
Le fichier du client porte le nom du serveur [localhost-100.txt] :
| --> [hello from client]
<-- [hello from server]
|
16.3. Obtenir le nom ou l'adresse IP d'une machine de l'Internet
Les machines de l'internet sont identifiées par une adresse IP (IPv4 ou IPv6) et le plus souvent par un nom. Mais finalement seule l'adresse IP est utilisée. Il faut donc parfois connaître l'adresse IP d'une machine identifiée par son nom. C'est le rôle du service DNS (Domain Name System).
Le script [inet/ip/ip-01.php] est le suivant :
| <?php
declare(strict_types=1);
// adresses IP et noms de machines de l'internet
// les machines dont on veut l'adresse IP et le nom
const HÔTES = ["istia.univ-angers.fr", "www.univ-angers.fr", "www.ibm.com", "www.php.net", "yahoo.com", "localhost", "xx"];
// on ajoute la machine locale, dont le nom est donné par [gethostname]
foreach ([...HÔTES, gethostname()] as $hôte) {
getIPandName($hôte);
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function getIPandName(string $nomMachine): void
{
// $nomMachine : nom de la machine dont on veut l'adresse IP
print "---------------\n";
// nomMachine --> adresse IP
$ip = gethostbyname($nomMachine);
if ($ip === $nomMachine) {
print "Erreur, machine [$nomMachine] non trouvée\n";
return;
}
print "ip[$nomMachine]=$ip\n";
// toutes les adresses IPv4 de la machine
$ips = gethostbynamel($nomMachine);
if ($ips !== false && count($ips) > 1) {
print "toutes les adresses IPv4 de [$nomMachine] : " . implode(", ", $ips) . "\n";
}
// adresse IP --> nomMachine
$nom = gethostbyaddr($ip);
if ($nom === false || $nom === $ip) {
print "Erreur, pas de nom pour l'adresse [$ip]\n";
} else {
print "name[$ip]=$nom\n";
}
}
|
Commentaires :
- ligne 8 : la liste de machines dont on veut le nom et l'adresse IP. PHP accepte les caractères accentués dans les identifiants ;
- ligne 11 : on ajoute à cette liste le nom de la machine locale, donné par la fonction [gethostname]. L'opérateur [...] recopie les éléments du tableau [HÔTES] dans le nouveau tableau ;
- ligne 18 : les fonctions réseau de PHP sont utilisées dans la fonction [getIPandName] ;
- ligne 23 : la fonction [gethostbyname($nom)] permet d'obtenir l'adresse IPv4 "ip3.ip2.ip1.ip0" de la machine s'appelant $nom. Si la machine $nom n'existe pas, la fonction rend $nom comme résultat ;
- ligne 30 : une machine peut avoir plusieurs adresses IPv4. La fonction [gethostbynamel] les rend toutes dans un tableau (ou [false] en cas d'échec) ;
- ligne 35 : la fonction [gethostbyaddr($ip)] permet d'obtenir le nom de la machine d'adresse $ip. Si l'adresse $ip n'a pas de nom, la fonction rend $ip comme résultat, et [false] si l'adresse est mal formée .
Les fonctions [gethostbyname] et [gethostbynamel] ne connaissent que les adresses IPv4. Pour obtenir les adresses IPv6 d'une machine, on utiliserait la fonction [dns_get_record] avec le type [DNS_AAAA].
Résultats :
| > php ip-01.php
---------------
ip[istia.univ-angers.fr]=193.49.144.41
name[193.49.144.41]=ametys-fo-2.univ-angers.fr
---------------
ip[www.univ-angers.fr]=193.49.144.41
name[193.49.144.41]=ametys-fo-2.univ-angers.fr
---------------
ip[www.ibm.com]=23.217.77.36
name[23.217.77.36]=a23-217-77-36.deploy.static.akamaitechnologies.com
---------------
ip[www.php.net]=107.150.176.1
name[107.150.176.1]=107-150-176-1.bunnyinfra.net
---------------
ip[yahoo.com]=98.137.11.163
toutes les adresses IPv4 de [yahoo.com] : 98.137.11.164, 98.137.11.163, 74.6.231.21, 74.6.143.25, 74.6.231.20, 74.6.143.26
name[98.137.11.163]=media-router-fp74.prod.media.vip.gq1.yahoo.com
---------------
ip[localhost]=127.0.0.1
name[127.0.0.1]=localhost
---------------
Erreur, machine [xx] non trouvée
---------------
ip[vm]=127.0.0.1
name[127.0.0.1]=localhost
Terminé
|
- les machines [istia.univ-angers.fr] et [www.univ-angers.fr] ont la même adresse IP. Le nom obtenu à partir de l'adresse IP, dit nom canonique, est encore un autre nom ;
- le nom de [www.ibm.com] est porté par une machine d'Akamai, une société qui héberge les sites de nombreuses entreprises ;
- la machine [yahoo.com] a six adresses IPv4 : les requêtes sont réparties sur plusieurs serveurs ;
- [localhost] a l'adresse 127.0.0.1, dite adresse de bouclage : elle désigne toujours la machine locale ;
- la machine [xx] n'existe pas ;
- la dernière machine est la machine locale, nommée ici [vm]. Les résultats dépendent de la configuration de la machine : sur un poste Windows, on obtiendra le nom de votre ordinateur et, selon la configuration, son adresse sur le réseau local (192.168.x.y par exemple) .
Les résultats obtenus pour les machines de l'internet évoluent dans le temps : vous n'aurez sans doute pas les mêmes.
16.4. Le protocole HTTP (HyperText Transfer Protocol)
16.4.1. Exemple 1

Lorsqu'un navigateur affiche une URL, il est le client d'un serveur web ou dit autrement d'un serveur HTTP. C'est lui qui prend l'initiative et il commence par envoyer un certain nombre de commandes au serveur. Pour ce premier exemple :
- le serveur sera l'utilitaire [tcp-server.php] ;
- le client sera un navigateur .
Nous lançons d'abord le serveur sur le port 100, puis avec un navigateur, nous demandons l'URL [http://localhost:100], c'est-à-dire que nous disons que le serveur HTTP interrogé travaille sur le port 100 de la machine locale. Le navigateur se met en attente de la réponse. Revenons sur la fenêtre du serveur. Nous y voyons les lignes envoyées par le navigateur. Nous tapons alors au clavier une réponse au format HTTP, ligne par ligne, puis nous fermons la connexion et arrêtons le serveur :
| > php tcp-server.php 100
Serveur à l'écoute sur le port [100]
Commandes : list, send id [texte], close id, quit
Client n° 1 [127.0.0.1:45752] connecté
<-- client n° 1 [GET / HTTP/1.1]
<-- client n° 1 [Host: localhost:100]
<-- client n° 1 [Connection: keep-alive]
<-- client n° 1 [sec-ch-ua: "HeadlessChrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"]
<-- client n° 1 [sec-ch-ua-mobile: ?0]
<-- client n° 1 [sec-ch-ua-platform: "Linux"]
<-- client n° 1 [Upgrade-Insecure-Requests: 1]
<-- client n° 1 [User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/141.0.7390.37 Safari/537.36]
<-- client n° 1 [Accept-Language: fr-FR]
<-- client n° 1 [Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7]
<-- client n° 1 [Sec-Fetch-Site: none]
<-- client n° 1 [Sec-Fetch-Mode: navigate]
<-- client n° 1 [Sec-Fetch-User: ?1]
<-- client n° 1 [Sec-Fetch-Dest: document]
<-- client n° 1 [Accept-Encoding: gzip, deflate, br, zstd]
<-- client n° 1 []
send 1 [HTTP/1.1 200 OK]
send 1 [Content-Type: text/html; charset=utf-8]
send 1 [Connection: close]
send 1 []
send 1 [<h1>Bonjour du serveur TCP !</h1>]
close 1
Connexion avec le client n° 1 fermée
list
quit
Serveur arrêté
|
- [Client n° 1] : le navigateur qui s'est connecté ;
- puis viennent les lignes de texte qu'il a envoyées :
- [GET / HTTP/1.1] : cette ligne a le format [GET URL HTTP/1.1]. Elle demande l'URL / et demande au serveur d'utiliser le protocole HTTP 1.1 ;
- [Host: localhost:100] : cette ligne a le format [Host: serveur:port]. La casse de l'entête [Host] n'importe pas. On rappelle ici que le client interroge un serveur local opérant sur le port 100 ;
- l'entête [Connection] indique le mode de connexion souhaité : [keep-alive] indique que la connexion doit être maintenue après la réponse, pour d'autres demandes éventuelles ;
- l'entête [User-Agent] donne l'identité du client. Ici, c'est un navigateur Chromium. Les entêtes [sec-ch-ua...] et [Sec-Fetch...] sont propres aux navigateurs récents. Les valeurs exactes dépendent du navigateur et du système utilisés ;
- l'entête [Accept] indique quels types de document sont acceptés par le client ;
- l'entête [Accept-Language] indique dans quelle langue sont souhaités les documents demandés s'ils existent en plusieurs langues ;
- l'entête [Accept-Encoding] indique que le client accepte des documents compressés ;
- le client termine ses commandes par une ligne vide ;
- les commandes [send 1 [...]] envoient la réponse au navigateur : une 1re ligne [HTTP/1.1 200 OK] qui indique que le document demandé a été trouvé, des entêtes, une ligne vide, puis le document. L'entête [Content-Type] indique le type du document, ici du HTML codé en UTF-8, et l'entête [Connection: close] que le serveur fermera la connexion à la fin du document ;
- [close 1] : la fermeture de la connexion indique au navigateur que le document est complet .
Le navigateur affiche alors le document reçu :

Nous venons d'écrire « à la main » un serveur web !
16.4.2. Exemple 2
Maintenant que nous connaissons les commandes envoyées par un navigateur pour réclamer une URL, nous allons réclamer une URL avec notre client TCP [tcp-client.php]. Le serveur Apache de [Laragon] sera notre serveur web : lancez-le (bouton [Start All] de [Laragon]). Le dossier [inet/http/pages] contient une page HTML statique [bonjour.html] :
| <!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Bonjour</title>
</head>
<body>
<h1>Bonjour !</h1>
<p>Une page statique servie par le serveur web.</p>
</body>
</html>
|
Le dossier [php8-oct-2026] étant dans le dossier [<laragon>/www], cette page est accessible à l'URL [http://localhost/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html]. Demandons-la avec notre client TCP, connecté au port 80 du serveur [localhost], là où opère le serveur web. Nous tapons les commandes que nous avons découvertes dans le paragraphe précédent :
| > php tcp-client.php localhost 80
Client [127.0.0.1:59734] connecté au serveur [localhost:80]
Tapez vos commandes (quit pour arrêter) :
GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
Host: localhost:80
<-- [HTTP/1.1 200 OK]
<-- [Date: Thu, 01 Oct 2026 15:32:51 GMT]
<-- [Server: Apache/2.4.58 (Ubuntu)]
<-- [Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT]
<-- [ETag: "ec-65cc8b40e550d"]
<-- [Accept-Ranges: bytes]
<-- [Content-Length: 236]
<-- [Vary: Accept-Encoding]
<-- [Content-Type: text/html]
<-- []
<-- [<!DOCTYPE html>]
<-- [<html lang="fr">]
<-- [ <head>]
<-- [ <meta charset="utf-8">]
<-- [ <title>Bonjour</title>]
<-- [ </head>]
<-- [ <body>]
<-- [ <h1>Bonjour !</h1>]
<-- [ <p>Une page statique servie par le serveur web.</p>]
<-- [ </body>]
<-- [</html>]
quit
Fin du client
|
- [GET ... HTTP/1.1] : la commande [GET] avec le chemin du document demandé ;
- [Host: localhost:80] : l'entête [Host] ;
- ce sont les deux seules commandes indispensables. Pour les autres entêtes, le serveur web prendra des valeurs par défaut ;
- la ligne vide qui doit terminer les commandes du client ;
- dessous la ligne vide vient la réponse du serveur web Apache :
- [HTTP/1.1 200 OK] : la ligne de statut. Le code 200 signifie que le document a été trouvé ;
- viennent ensuite les entêtes HTTP de la réponse, jusqu'à une ligne vide. L'entête [Content-Length] donne la taille du document en octets ;
- après la ligne vide vient le document HTML demandé ;
- l'entête [Server] identifie le serveur web : ici un serveur Apache 2.4. Avec [Laragon], vous verrez la mention [(Win64)] et les versions d'OpenSSL et de PHP ;
- [quit] : nous tapons [quit] pour terminer le client. Le serveur, lui, garde la connexion ouverte quelques secondes (mode keep-alive de HTTP/1.1) en attendant une autre demande, puis la ferme .
Le fichier de logs [utilitaires/logs/localhost-80.txt] contient les échanges :
| --> [GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1]
--> [Host: localhost:80]
--> []
<-- [HTTP/1.1 200 OK]
<-- [Date: Thu, 01 Oct 2026 15:32:51 GMT]
<-- [Server: Apache/2.4.58 (Ubuntu)]
<-- [Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT]
<-- [ETag: "ec-65cc8b40e550d"]
<-- [Accept-Ranges: bytes]
<-- [Content-Length: 236]
<-- [Vary: Accept-Encoding]
<-- [Content-Type: text/html]
<-- []
<-- [<!DOCTYPE html>]
<-- [<html lang="fr">]
<-- [ <head>]
<-- [ <meta charset="utf-8">]
<-- [ <title>Bonjour</title>]
<-- [ </head>]
<-- [ <body>]
<-- [ <h1>Bonjour !</h1>]
<-- [ <p>Une page statique servie par le serveur web.</p>]
<-- [ </body>]
<-- [</html>]
|
Nous avons désormais les bases pour programmer un client TCP qui demanderait une URL.
16.4.3. Exemple 3

Le script [inet/http/http-01.php] est un client HTTP configuré par le fichier JSON [config-http-01.json]. Le contenu de celui-ci est le suivant :
| {
"localhost": {
"port": 80,
"GET": "/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html",
"Host": "localhost:80",
"User-Agent": "client PHP",
"Accept": "text/html",
"Accept-Language": "fr",
"Connection": "close",
"endOfLine": "\r\n"
}
}
|
- ligne 2 : le nom de la machine hébergeant le serveur web à atteindre ;
- ligne 3 : le port sur lequel opère ce serveur web ;
- ligne 4 : l'URL du document désiré ;
- ligne 5 : la machine cible sous la forme machine:port ;
- ligne 6 : l'identification du client HTTP : on peut mettre ce qu'on veut ;
- ligne 7 : le type de document accepté par le client, ici du texte HTML ;
- ligne 8 : la langue souhaitée pour le document demandé ;
- ligne 9 : on demande au serveur de fermer la connexion une fois le document envoyé. Sans cet entête, un serveur HTTP/1.1 garde la connexion ouverte quelques secondes en attendant une autre demande, et notre client, qui lit jusqu'à la fermeture de la connexion, attendrait inutilement ;
- ligne 10 : la marque de fin de ligne pour les commandes envoyées par le client. Le protocole HTTP demande la séquence \r\n .
Le script [http-01.php] est le suivant :
| <?php
declare(strict_types=1);
// client HTTP basique : demande un document à un serveur web et l'enregistre dans un fichier
// le fichier de configuration peut être passé en argument (php http-01.php config.json)
$configFileName = $argv[1] ?? "config-http-01.json";
// on récupère la configuration
$config = json_decode(file_get_contents($configFileName), true);
// obtenir le texte HTML des URL du fichier de configuration
foreach ($config as $site => $protocole) {
// lecture de l'URL du site $site
$résultat = getURL($site, $protocole);
// affichage résultat
print "$résultat\n";
}
// --------------------------------------------------------------------------
function getURL(string $site, array $protocole, bool $suivi = true): string
{
// lit l'URL $protocole["GET"] du site $site et la stocke dans le fichier output/$site.html
// le dialogue client / serveur se fait selon le protocole $protocole
//
// ouverture d'une connexion sur le port $protocole["port"] de $site
$connexion = @stream_socket_client("tcp://$site:{$protocole["port"]}", $numErreur, $erreur, 10);
// retour si erreur
if ($connexion === false) {
return "Échec de la connexion au site [$site:{$protocole["port"]}] : $erreur";
}
// $connexion représente un flux de communication bidirectionnel
// entre le client (ce programme) et le serveur web contacté
// ce canal est utilisé pour les échanges de commandes et d'informations
// le protocole de dialogue est HTTP
//
// création du fichier output/$site.html
if (!is_dir("output")) {
mkdir("output");
}
$html = fopen("output/$site.html", "w");
if ($html === false) {
fclose($connexion);
return "Erreur lors de la création du fichier [output/$site.html]";
}
// le client va commencer le dialogue HTTP avec le serveur
if ($suivi) {
print "Client : début de la communication avec le serveur [$site] ----------------------------\n";
}
// les lignes envoyées par le client doivent se terminer par \r\n
$endOfLine = $protocole["endOfLine"];
// par simplification, on ne teste pas les cas d'erreur dans la communication client / serveur
// le client envoie la commande GET pour demander l'URL $protocole["GET"]
// syntaxe GET URL HTTP/1.1
$commande = "GET {$protocole["GET"]} HTTP/1.1$endOfLine";
if ($suivi) {
print "--> $commande";
}
fwrite($connexion, $commande);
// émission des autres entêtes HTTP
foreach ($protocole as $verb => $value) {
if (!in_array($verb, ["GET", "port", "endOfLine"], true)) {
// on construit la commande
$commande = "$verb: $value$endOfLine";
if ($suivi) {
print "--> $commande";
}
// on envoie la commande au serveur
fwrite($connexion, $commande);
}
}
// les entêtes (headers) du protocole HTTP doivent se terminer par une ligne vide
fwrite($connexion, $endOfLine);
//
// le serveur va maintenant répondre sur le canal $connexion. Il va envoyer toutes
// ses données puis fermer le canal (Connection: close). Le client lit donc tout ce
// qui arrive de $connexion jusqu'à la fermeture du canal
//
// on lit tout d'abord les entêtes HTTP envoyés par le serveur
// ils se terminent eux aussi par une ligne vide
if ($suivi) {
print "Réponse du serveur [$site] ----------------------------\n";
}
while (($ligne = fgets($connexion)) !== false) {
// on enlève les espaces de fin de ligne (dont \r\n)
$ligne = rtrim($ligne);
// a-t-on la ligne vide qui termine les entêtes ?
if ($ligne === "") {
break;
}
// on affiche l'entête HTTP
if ($suivi) {
print "<-- $ligne\n";
}
}
// on lit le document HTML qui suit la ligne vide et on le mémorise dans le fichier
while (($ligne = fgets($connexion)) !== false) {
fwrite($html, $ligne);
}
// le serveur a fermé la connexion - le client la ferme à son tour
fclose($connexion);
fclose($html);
// retour
return "Fin de la communication avec le site [$site]. Vérifiez le fichier [output/$site.html]";
}
|
Commentaires du code :
- ligne 7 : le fichier de configuration peut être passé en argument. Nous utiliserons plusieurs fichiers de configuration ;
- ligne 10 : le fichier de configuration est exploité pour créer un dictionnaire :
- les clés du dictionnaire sont les serveurs web à interroger ;
- les valeurs fixent le protocole HTTP à respecter ;
- ligne 12 : on boucle sur la liste des serveurs web de la configuration ;
- ligne 20 : la fonction [getURL($site, $protocole, $suivi)] demande un document du site web $site et le stocke dans le fichier texte [output/$site.html]. Par défaut, les échanges client / serveur sont affichés sur la console ($suivi=true) ;
- ligne 26 : la fonction [stream_socket_client] permet de créer une connexion avec un service TCP / IP travaillant sur le port [port] de la machine $site. Une fois la connexion client / serveur ouverte, de nombreux services TCP / IP échangent des lignes de texte. C'est le cas ici du protocole HTTP. Le flux du serveur parvenant au client peut alors être traité comme un fichier texte lu avec [fgets]. Il en est de même pour le flux partant du client vers le serveur qui peut être écrit avec [fwrite] ;
- ligne 40 : création du fichier [output/$site.html] dans lequel on stockera le document HTML reçu ;
- ligne 54 : la première commande du client doit être la commande [GET URL HTTP/1.1]. Elle signifie : « Je veux (GET) la page [URL] du site web auquel je suis connecté. Je travaille avec le protocole HTTP version 1.1 » ;
- lignes 60-70 : on envoie les autres lignes du protocole HTTP [Host, User-Agent, Accept, Accept-Language, Connection]. Leur ordre n'importe pas. Le cours PHP 7 testait les clés à exclure une à une ; la fonction [in_array] avec le 3e paramètre [true] (comparaison stricte) est plus lisible ;
- ligne 72 : on envoie une ligne vide au serveur pour signifier que le client a terminé d'envoyer ses entêtes HTTP et qu'il attend désormais le document demandé ;
- lignes 83-94 : le serveur va tout d'abord envoyer une série d'entêtes HTTP qui vont donner diverses informations sur le document demandé. Ces entêtes se terminent par une ligne vide. On lit une ligne avec [fgets], on lui enlève les espaces de fin (dont \r\n) avec [rtrim] et on regarde si c'est la ligne vide qui marque la fin des entêtes. Sinon l'entête est affiché ;
- lignes 95-97 : les lignes de texte du document peuvent être lues ligne par ligne avec une boucle while et enregistrées dans le fichier texte [output/$site.html]. Lorsque le serveur web a envoyé la totalité du document, il ferme sa connexion avec le client. Côté client, cela sera détecté comme une fin de fichier : [fgets] rend [false] .
Exécutons le script dans un terminal ouvert dans le dossier [inet/http]. La console affiche les logs suivants :
| > php http-01.php
Client : début de la communication avec le serveur [localhost] ----------------------------
--> GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
--> Host: localhost:80
--> User-Agent: client PHP
--> Accept: text/html
--> Accept-Language: fr
--> Connection: close
Réponse du serveur [localhost] ----------------------------
<-- HTTP/1.1 200 OK
<-- Date: Thu, 01 Oct 2026 16:47:43 GMT
<-- Server: Apache/2.4.58 (Ubuntu)
<-- Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT
<-- ETag: "ec-65cc8b40e550d"
<-- Accept-Ranges: bytes
<-- Content-Length: 236
<-- Vary: Accept-Encoding
<-- Connection: close
<-- Content-Type: text/html
Fin de la communication avec le site [localhost]. Vérifiez le fichier [output/localhost.html]
|
Dans notre exemple, le fichier [output/localhost.html] reçu est le suivant :
| <!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Bonjour</title>
</head>
<body>
<h1>Bonjour !</h1>
<p>Une page statique servie par le serveur web.</p>
</body>
</html>
|
C'est bien le document [bonjour.html] : c'est aussi celui qu'affiche un navigateur.
16.4.4. Exemple 4
Dans cet exemple, nous allons montrer que le client HTTP que nous avons écrit est insuffisant.
16.4.4.1. Le protocole HTTPS
Faisons évoluer le fichier de configuration. Le fichier [config-http-01-https.json] est le suivant :
| {
"localhost": {
"port": 443,
"GET": "/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html",
"Host": "localhost:443",
"User-Agent": "client PHP",
"Accept": "text/html",
"Accept-Language": "fr",
"Connection": "close",
"endOfLine": "\r\n"
}
}
|
Ici, nous allons demander le document [bonjour.html] au port 443 du serveur [localhost]. Le port 443 est le port utilisé par le protocole HTTP sécurisé appelé HTTPS. Dans ce protocole, le dialogue client / serveur commence par un échange d'informations qui vont sécuriser la liaison : elle sera chiffrée par le protocole TLS. Le client doit alors parler le protocole HTTPS et non le protocole HTTP, ce que ne fait pas notre client.
Le serveur Apache de [Laragon] sait utiliser le protocole HTTPS, mais celui-ci n'est pas actif par défaut. On l'active par le menu de [Laragon] : clic droit dans la fenêtre de [Laragon], puis [Apache / SSL / Enabled]. Le serveur Apache est alors redémarré et écoute désormais sur deux ports : le port 80 (HTTP) et le port 443 (HTTPS). [Laragon] a généré pour lui un certificat dit autosigné. Nous reviendrons sur ce point au chapitre suivant.
Avec ce fichier de configuration ([php http-01.php config-http-01-https.json]), les résultats de la console sont les suivants :
| > php http-01.php config-http-01-https.json
Client : début de la communication avec le serveur [localhost] ----------------------------
--> GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
--> Host: localhost:443
--> User-Agent: client PHP
--> Accept: text/html
--> Accept-Language: fr
--> Connection: close
Réponse du serveur [localhost] ----------------------------
<-- HTTP/1.1 400 Bad Request
<-- Date: Thu, 01 Oct 2026 16:47:43 GMT
<-- Server: Apache/2.4.58 (Ubuntu)
<-- Content-Length: 438
<-- Connection: close
<-- Content-Type: text/html; charset=iso-8859-1
Fin de la communication avec le site [localhost]. Vérifiez le fichier [output/localhost.html]
|
- [HTTP/1.1 400 Bad Request] : le serveur a répondu que la requête du client était incorrecte .
Le contenu du fichier [output/localhost.html] est alors le suivant :
| <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
Reason: You're speaking plain HTTP to an SSL-enabled server port.<br />
Instead use the HTTPS scheme to access this URL, please.<br />
</p>
<hr>
<address>Apache/2.4.58 (Ubuntu) Server at 127.0.0.1 Port 443</address>
</body></html>
|
Le serveur dit clairement que nous n'avons pas utilisé le bon protocole : « You're speaking plain HTTP to an SSL-enabled server port » (vous parlez HTTP à un port réservé à SSL, l'ancien nom de TLS).
16.4.4.2. Document envoyé par morceaux
Le dossier [inet/http/pages] contient un second document, le script PHP [index.php] :
| <?php
declare(strict_types=1);
// une page envoyée en plusieurs fois : [flush] force l'envoi immédiat de ce qui a déjà été écrit
// le serveur ne connaît donc pas la taille totale du document au moment d'envoyer les entêtes
print "<!DOCTYPE html>\n<html lang=\"fr\">\n<head><meta charset=\"utf-8\"><title>Morceaux</title></head>\n<body>\n";
flush();
for ($i = 1; $i <= 3; $i++) {
print "<p>morceau n° $i</p>\n";
flush();
}
print "</body>\n</html>\n";
|
- ligne 8 : la fonction [flush] demande l'envoi immédiat au client de ce que le script a déjà écrit. Le script envoie ainsi son document en cinq fois. Au moment d'envoyer les entêtes HTTP de la réponse, le serveur ne connaît donc pas la taille totale du document .
Utilisons maintenant le fichier de configuration [config-http-01-morceaux.json] suivant :
| {
"localhost": {
"port": 80,
"GET": "/php8-oct-2026/scripts-console/inet/http/pages/",
"Host": "localhost:80",
"User-Agent": "client PHP",
"Accept": "text/html",
"Accept-Language": "fr",
"Connection": "close",
"endOfLine": "\r\n"
}
}
|
- ligne 4 : on demande le dossier [pages/]. Le serveur Apache envoie alors son document par défaut, ici [index.php] .
Les résultats console sont alors les suivants :
| > php http-01.php config-http-01-morceaux.json
Client : début de la communication avec le serveur [localhost] ----------------------------
--> GET /php8-oct-2026/scripts-console/inet/http/pages/ HTTP/1.1
--> Host: localhost:80
--> User-Agent: client PHP
--> Accept: text/html
--> Accept-Language: fr
--> Connection: close
Réponse du serveur [localhost] ----------------------------
<-- HTTP/1.1 200 OK
<-- Date: Thu, 01 Oct 2026 16:47:43 GMT
<-- Server: Apache/2.4.58 (Ubuntu)
<-- X-Powered-By: PHP/8.5.11
<-- Vary: Accept-Encoding
<-- Connection: close
<-- Transfer-Encoding: chunked
<-- Content-Type: text/html; charset=UTF-8
Fin de la communication avec le site [localhost]. Vérifiez le fichier [output/localhost.html]
|
- l'entête [X-Powered-By] indique que le document a été produit par PHP 8.5 ;
- l'entête [Transfer-Encoding: chunked] indique que le serveur envoie le document par morceaux (chunks). Il n'y a pas d'entête [Content-Length] : le serveur n'annonce pas la taille du document .
Cela se traduit par la présence de nombres dans le flux envoyé au client : chaque morceau est précédé d'une ligne indiquant sa taille en hexadécimal, et le document se termine par un morceau de taille 0. Voici le fichier [output/localhost.html] :
| 63
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>Morceaux</title></head>
<body>
15
<p>morceau n° 1</p>
15
<p>morceau n° 2</p>
15
<p>morceau n° 3</p>
10
</body>
</html>
0
|
- [63] : la taille en hexadécimal du 1er morceau, soit 99 octets : les quatre premières lignes du document ;
- [15] : la taille des trois morceaux suivants, soit 21 octets (le caractère [°] occupe deux octets en UTF-8) ;
- [10] : la taille du dernier morceau, 16 octets ;
- [0] : le morceau de taille 0 qui termine le document .
Un client HTTP correct ne devrait pas laisser ces nombres dans le document final.
16.4.4.3. Redirection
Voici un dernier fichier de configuration [config-http-01-redirection.json] :
| {
"localhost": {
"port": 80,
"GET": "/php8-oct-2026/scripts-console/inet/http/pages",
"Host": "localhost:80",
"User-Agent": "client PHP",
"Accept": "text/html",
"Accept-Language": "fr",
"Connection": "close",
"endOfLine": "\r\n"
}
}
|
Il ressemble à l'exemple précédent mais l'URL demandée n'a pas le caractère / pour la terminer. Ce ne sont pas les mêmes URL. L'exécution du client HTTP donne alors les résultats console suivants :
| > php http-01.php config-http-01-redirection.json
Client : début de la communication avec le serveur [localhost] ----------------------------
--> GET /php8-oct-2026/scripts-console/inet/http/pages HTTP/1.1
--> Host: localhost:80
--> User-Agent: client PHP
--> Accept: text/html
--> Accept-Language: fr
--> Connection: close
Réponse du serveur [localhost] ----------------------------
<-- HTTP/1.1 301 Moved Permanently
<-- Date: Thu, 01 Oct 2026 16:47:43 GMT
<-- Server: Apache/2.4.58 (Ubuntu)
<-- Location: http://localhost/php8-oct-2026/scripts-console/inet/http/pages/
<-- Content-Length: 346
<-- Connection: close
<-- Content-Type: text/html; charset=iso-8859-1
Fin de la communication avec le site [localhost]. Vérifiez le fichier [output/localhost.html]
|
- [HTTP/1.1 301 Moved Permanently] : le code 301 indique que le document demandé a changé d'URL de façon définitive ;
- [Location: ...] : la nouvelle URL. Notez cette fois-ci le caractère / qui la termine : [pages] est un dossier, et le serveur Apache demande au client de le désigner par une URL terminée par / .
Le fichier [output/localhost.html] est alors le suivant :
| <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>301 Moved Permanently</title>
</head><body>
<h1>Moved Permanently</h1>
<p>The document has moved <a href="http://localhost/php8-oct-2026/scripts-console/inet/http/pages/">here</a>.</p>
<hr>
<address>Apache/2.4.58 (Ubuntu) Server at localhost Port 80</address>
</body></html>
|
Un client HTTP devrait pouvoir suivre les redirections. Ici il devrait redemander automatiquement la nouvelle URL [http://localhost/php8-oct-2026/scripts-console/inet/http/pages/].
16.4.5. Exemple 5
Les exemples précédents nous ont montré que notre client HTTP était insuffisant. Nous allons maintenant présenter un outil appelé [curl] qui permet de récupérer des documents web en gérant les difficultés mentionnées : protocole HTTPS, document envoyé par morceaux, redirections… L'outil [curl] est livré avec Windows (depuis Windows 10) et avec [Laragon]. Dans un terminal PowerShell, il faut taper [curl.exe] et non [curl] : dans PowerShell 5, [curl] est un alias d'une autre commande, [Invoke-WebRequest].
La commande [curl --help] fait afficher les principales options de configuration de [curl]. Il y en a plusieurs dizaines. Nous en utiliserons très peu. Pour demander une URL il suffit de taper la commande [curl URL]. Cette commande affichera sur la console le document demandé. Si on veut de plus les échanges HTTP entre le client et le serveur on écrira [curl --verbose URL]. Enfin pour enregistrer le document HTML demandé dans un fichier on écrira [curl --verbose --output fichier URL].
Pour éviter de polluer les dossiers du cours, plaçons-nous dans un dossier de travail, par exemple [c:\temp\curl] (créez-le s'il n'existe pas), et demandons la page [bonjour.html] :
| > curl --verbose --output localhost.html http://localhost/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html
* Host localhost:80 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80
> GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
> Host: localhost
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 01 Oct 2026 15:32:56 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT
< ETag: "ec-65cc8b40e550d"
< Accept-Ranges: bytes
< Content-Length: 236
< Vary: Accept-Encoding
< Content-Type: text/html
<
{ [236 bytes data]
100 236 100 236 0 0 306k 0 --:--:-- --:--:-- --:--:-- 230k
* Connection #0 to host localhost left intact
|
- les lignes commençant par [*] sont des informations de [curl] : il a trouvé deux adresses pour [localhost], l'adresse IPv6 [::1] et l'adresse IPv4 [127.0.0.1], et s'est connecté à la seconde ;
- les lignes commençant par [>] sont les lignes envoyées par [curl] au serveur [localhost]. On reconnaît le protocole HTTP ;
- les lignes commençant par [<] sont les lignes envoyées en réponse par le serveur ;
- les autres lignes sont une barre de progression du téléchargement .
Le fichier [localhost.html] contient le document demandé. Vous pouvez le vérifier en chargeant le fichier dans un éditeur de texte.
Maintenant demandons l'URL [https://localhost/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html]. Pour avoir cette URL, le client HTTP doit savoir parler HTTPS. C'est le cas du client [curl]. Le certificat du serveur est autosigné : [curl] refuserait de poursuivre. L'option [--insecure] lui demande de ne pas vérifier le certificat. Les résultats console sont les suivants :
| > curl --verbose --insecure --output localhost-https.html https://localhost/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html
* Host localhost:443 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:443...
* Connected to localhost (127.0.0.1) port 443
* ALPN: curl offers h2,http/1.1
} [5 bytes data]
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
} [512 bytes data]
* TLSv1.3 (IN), TLS handshake, Server hello (2):
{ [122 bytes data]
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
{ [21 bytes data]
* TLSv1.3 (IN), TLS handshake, Certificate (11):
{ [822 bytes data]
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
{ [264 bytes data]
* TLSv1.3 (IN), TLS handshake, Finished (20):
{ [52 bytes data]
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
} [1 bytes data]
* TLSv1.3 (OUT), TLS handshake, Finished (20):
} [52 bytes data]
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=localhost
* start date: Oct 1 14:55:35 2026 GMT
* expire date: Sep 28 14:55:35 2036 GMT
* issuer: CN=localhost
* SSL certificate verify result: self-signed certificate (18), continuing anyway.
* Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/1.x
} [5 bytes data]
> GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
> Host: localhost
> User-Agent: curl/8.5.0
> Accept: */*
>
{ [5 bytes data]
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
{ [57 bytes data]
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
{ [57 bytes data]
* old SSL session ID is stale, removing
{ [5 bytes data]
< HTTP/1.1 200 OK
< Date: Thu, 01 Oct 2026 15:32:56 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT
< ETag: "ec-65cc8b40e550d"
< Accept-Ranges: bytes
< Content-Length: 236
< Vary: Accept-Encoding
< Content-Type: text/html
<
{ [236 bytes data]
100 236 100 236 0 0 45798 0 --:--:-- --:--:-- --:--:-- 47200
* Connection #0 to host localhost left intact
|
- les lignes [TLSv1.3 ...] montrent les échanges client / serveur pour sécuriser la connexion : celle-ci sera chiffrée ;
- les lignes [Server certificate] décrivent le certificat envoyé par le serveur. Il a été émis pour la machine [localhost] (CN=localhost) par cette même machine (issuer: CN=localhost) : c'est un certificat autosigné. [curl] signale qu'il n'a pas pu le vérifier mais continue (continuing anyway) ;
- viennent ensuite, chiffrés, les échanges HTTP habituels. [HTTP/1.1 200 OK] : le document demandé a bien été trouvé .
Demandons maintenant l'URL [http://localhost/php8-oct-2026/scripts-console/inet/http/pages]. Nous avions vu que pour cette URL, il y avait une redirection vers la même URL terminée par le caractère /. Les résultats console sont alors les suivants :
| > curl --verbose --location --output pages.html http://localhost/php8-oct-2026/scripts-console/inet/http/pages
* Host localhost:80 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80
> GET /php8-oct-2026/scripts-console/inet/http/pages HTTP/1.1
> Host: localhost
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Date: Thu, 01 Oct 2026 15:32:56 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Location: http://localhost/php8-oct-2026/scripts-console/inet/http/pages/
< Content-Length: 346
< Content-Type: text/html; charset=iso-8859-1
<
* Ignoring the response-body
100 346 100 346 0 0 637k 0 --:--:-- --:--:-- --:--:-- 337k
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost/php8-oct-2026/scripts-console/inet/http/pages/'
* Found bundle for host: 0x562ea38dc400 [serially]
* Can not multiplex, even if we wanted to
* Re-using existing connection with host localhost
> GET /php8-oct-2026/scripts-console/inet/http/pages/ HTTP/1.1
> Host: localhost
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Thu, 01 Oct 2026 15:32:56 GMT
< Server: Apache/2.4.58 (Ubuntu)
< X-Powered-By: PHP/8.5.11
< Vary: Accept-Encoding
< Transfer-Encoding: chunked
< Content-Type: text/html; charset=UTF-8
<
{ [99 bytes data]
100 178 0 178 0 0 147k 0 --:--:-- --:--:-- --:--:-- 147k
* Connection #0 to host localhost left intact
|
- on utilise l'option [--location] pour indiquer qu'on veut suivre les redirections envoyées par le serveur ;
- [HTTP/1.1 301 Moved Permanently] et [Location] : le serveur indique que le document demandé est à une autre URL ;
- [Issue another request to this URL] : [curl] émet une nouvelle requête, cette fois vers la nouvelle URL, en réutilisant la même connexion ;
- [HTTP/1.1 200 OK] : le serveur répond qu'il a trouvé le document demandé ;
- [Transfer-Encoding: chunked] : il l'envoie par morceaux. [curl] enlève les tailles des morceaux .
Le document demandé sera trouvé dans le fichier [pages.html].
16.4.6. Exemple 6
PHP possède une extension appelée [curl] qui permet d'utiliser les capacités de l'outil [curl] dans un programme PHP. Elle est activée dans le PHP de [Laragon] : on peut le vérifier avec la commande [php -m] qui liste les extensions chargées. Si elle ne l'était pas, il faudrait décommenter la ligne [extension=curl] du fichier [php.ini].
Nous allons écrire un script [http-02.php] qui exploitera le fichier de configuration JSON [config-http-02.json] suivant :
| {
"localhost": {
"timeout": 5,
"url": "http://localhost/php8-oct-2026/scripts-console/inet/http/pages"
},
"localhost-https": {
"timeout": 5,
"url": "https://localhost/php8-oct-2026/scripts-console/inet/http/pages/bonjour.html",
"verify-peer": false
}
}
|
Chaque élément du dictionnaire [clé => valeur] a la structure suivante :
- clé : un nom qui servira à nommer le fichier où sera enregistré le document ;
- valeur est un dictionnaire avec les clés suivantes :
- timeout : durée maximale d'attente de la réponse du serveur. Au-delà, le client se déconnectera ;
- url : URL du document demandé ;
- verify-peer : faut-il vérifier le certificat du serveur (HTTPS) ? Vrai par défaut .
Le code du script [http-02.php] est le suivant :
| <?php
declare(strict_types=1);
// client HTTP écrit avec l'extension [curl] de PHP
// sait parler HTTPS, suivre les redirections et recevoir un document envoyé par morceaux
// constantes
const CONFIG_FILE_NAME = "config-http-02.json";
// on récupère la configuration
$config = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// obtenir le texte HTML des URL du fichier de configuration
foreach ($config as $site => $infos) {
// lecture de l'URL du site $site
$résultat = getUrl($site, $infos["url"], $infos["timeout"], $infos["verify-peer"] ?? true);
// affichage résultat
print "$résultat\n";
}
// --------------------------------------------------------------------------
function getUrl(string $site, string $url, int $timeout, bool $verifyPeer = true): string
{
// lit l'URL $url et la stocke dans le fichier output/$site.html
//
// suivi
print "Client : début de la communication avec le serveur [$site] ----------------------------\n";
// initialisation d'une session cURL : depuis PHP 8.0, [curl_init] rend un objet [CurlHandle]
$curl = curl_init($url);
if ($curl === false) {
return "Erreur lors de l'initialisation de la session cURL pour le site [$site]";
}
// options de curl
$options = [
// mode verbose : les échanges client / serveur sont affichés...
CURLOPT_VERBOSE => true,
// ... sur la sortie standard (par défaut, c'est la sortie d'erreur)
CURLOPT_STDERR => STDOUT,
// nouvelle connexion - pas de cache
CURLOPT_FRESH_CONNECT => true,
// timeout de la requête (en secondes)
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
// vérifier ou non la validité du certificat du serveur (HTTPS)
CURLOPT_SSL_VERIFYPEER => $verifyPeer,
CURLOPT_SSL_VERIFYHOST => $verifyPeer ? 2 : 0,
// suivre les redirections
CURLOPT_FOLLOWLOCATION => true,
// récupération du document demandé sous la forme d'une chaîne de caractères
CURLOPT_RETURNTRANSFER => true,
];
// paramétrage de curl
curl_setopt_array($curl, $options);
// exécution de la requête
$pageContent = curl_exec($curl);
// la session cURL est fermée automatiquement quand l'objet $curl disparaît
// ([curl_close] est inutile depuis PHP 8.0 et obsolète depuis PHP 8.5)
//
// exploitation du résultat
if ($pageContent === false) {
// il y a eu une erreur de communication
return "Erreur de communication avec le serveur [$site] : " . curl_error($curl);
}
// enregistrement du résultat dans output/$site.html
if (!is_dir("output")) {
mkdir("output");
}
if (file_put_contents("output/$site.html", $pageContent) === false) {
return "Erreur lors de la création du fichier [output/$site.html]";
}
// retour avec succès
return "Fin de la communication avec le serveur [$site]. Vérifiez le fichier [output/$site.html]";
}
|
Commentaires :
- ligne 12 : on exploite le fichier de configuration pour créer le dictionnaire [$config] ;
- ligne 15 : on boucle sur la liste de sites trouvés dans la configuration ;
- ligne 17 : pour chacun des sites, on appelle la fonction [getUrl] qui va télécharger l'URL [$infos["url"]] avec un timeout [$infos["timeout"]] ;
- ligne 30 : on démarre une session [curl]. [curl_init] ne fait pas encore de connexion au serveur web. Depuis PHP 8.0, elle rend un objet de la classe [CurlHandle] (et non plus une ressource) qui va être un paramètre pour toutes les fonctions [curl] suivantes. Si l'initialisation de la session échoue, la fonction rend le booléen [false] ;
- lignes 35-52 : le dictionnaire [$options] va paramétrer la connexion [curl] au serveur :
- [CURLOPT_VERBOSE] : affiche les échanges client / serveur, comme l'option [--verbose] de l'outil [curl]. [CURLOPT_STDERR] les envoie sur la sortie standard [STDOUT] plutôt que sur la sortie d'erreur, ce qui garde l'ordre des affichages ;
- [CURLOPT_SSL_VERIFYPEER] et [CURLOPT_SSL_VERIFYHOST] : la vérification du certificat du serveur et du nom qu'il contient ;
- [CURLOPT_FOLLOWLOCATION] : suivre les redirections, comme l'option [--location] ;
- [CURLOPT_RETURNTRANSFER] : rendre le document reçu sous la forme d'une chaîne de caractères ;
- ligne 54 : les options de la connexion sont transmises à l'objet [$curl] ;
- ligne 56 : connexion à l'URL demandée avec les options définies. À cause de l'option [CURLOPT_RETURNTRANSFER => true], la fonction [curl_exec] rend comme résultat le document envoyé par le serveur comme une chaîne de caractères, débarrassé des tailles des morceaux s'il a été envoyé par morceaux. La fonction [curl_exec] rend le booléen [false] en cas d'échec de la connexion. La fonction [curl_error] donne alors la cause de l'erreur ;
- ligne 57 : le cours PHP 7 fermait la session avec la fonction [curl_close]. Depuis PHP 8.0, celle-ci ne fait plus rien : la session est fermée lorsque l'objet [CurlHandle] n'est plus utilisé. Elle est obsolète depuis PHP 8.5 ;
- ligne 69 : on enregistre la page reçue dans un fichier local .
Lorsqu'on exécute le script [http-02.php] on obtient les résultats console suivants :
| > php http-02.php
Client : début de la communication avec le serveur [localhost] ----------------------------
* Host localhost:80 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80
> GET /php8-oct-2026/scripts-console/inet/http/pages HTTP/1.1
Host: localhost
Accept: */*
< HTTP/1.1 301 Moved Permanently
< Date: Thu, 01 Oct 2026 16:47:51 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Location: http://localhost/php8-oct-2026/scripts-console/inet/http/pages/
< Content-Length: 346
< Content-Type: text/html; charset=iso-8859-1
<
* Ignoring the response-body
* Connection #0 to host localhost left intact
* Issue another request to this URL: 'http://localhost/php8-oct-2026/scripts-console/inet/http/pages/'
* Found bundle for host: 0x557b108f4920 [serially]
* Can not multiplex, even if we wanted to
* Re-using existing connection with host localhost
> GET /php8-oct-2026/scripts-console/inet/http/pages/ HTTP/1.1
Host: localhost
Accept: */*
< HTTP/1.1 200 OK
< Date: Thu, 01 Oct 2026 16:47:51 GMT
< Server: Apache/2.4.58 (Ubuntu)
< X-Powered-By: PHP/8.5.11
< Vary: Accept-Encoding
< Transfer-Encoding: chunked
< Content-Type: text/html; charset=UTF-8
<
* Connection #0 to host localhost left intact
Fin de la communication avec le serveur [localhost]. Vérifiez le fichier [output/localhost.html]
Client : début de la communication avec le serveur [localhost-https] ----------------------------
* Host localhost:443 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying 127.0.0.1:443...
* Connected to localhost (127.0.0.1) port 443
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=localhost
* start date: Oct 1 14:55:35 2026 GMT
* expire date: Sep 28 14:55:35 2036 GMT
* issuer: CN=localhost
* SSL certificate verify result: self-signed certificate (18), continuing anyway.
* Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/1.x
> GET /php8-oct-2026/scripts-console/inet/http/pages/bonjour.html HTTP/1.1
Host: localhost
Accept: */*
* old SSL session ID is stale, removing
< HTTP/1.1 200 OK
< Date: Thu, 01 Oct 2026 16:47:51 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Last-Modified: Thu, 01 Oct 2026 15:02:24 GMT
< ETag: "ec-65cc8b40e550d"
< Accept-Ranges: bytes
< Content-Length: 236
< Vary: Accept-Encoding
< Content-Type: text/html
<
* Connection #0 to host localhost left intact
Fin de la communication avec le serveur [localhost-https]. Vérifiez le fichier [output/localhost-https.html]
|
On obtient les mêmes échanges qu'avec l'outil [curl] : les lignes commençant par [>] sont envoyées au serveur, celles commençant par [<] sont reçues du serveur, celles commençant par [*] sont des informations de [curl]. La redirection a été suivie et la connexion HTTPS a été établie.
16.4.7. Conclusion
Nous avons, dans ce paragraphe, découvert le protocole HTTP et avons écrit un script [http-02.php] capable de télécharger une URL du web. Au chapitre suivant, nous utiliserons pour cela le composant [HttpClient] du framework Symfony.
16.5. Le protocole SMTP (Simple Mail Transfer Protocol)
16.5.1. Introduction

Dans ce chapitre :
- [Serveur B] sera un serveur SMTP local que nous installerons ;
- [Client A] sera un client SMTP de diverses formes :
- le client [tcp-client.php] pour découvrir le protocole SMTP ;
- un script PHP rejouant le protocole SMTP découvert avec [tcp-client.php] ;
- un script PHP utilisant la bibliothèque [Symfony Mailer] permettant d'envoyer toutes sortes de mails .
16.5.2. Installation d'un serveur SMTP de test : [Mailpit]
Le cours PHP 7 installait le serveur de mails [hMailServer], qui n'est plus maintenu, et le configurait pour faire relayer les mails par le serveur SMTP de Gmail. Nous utiliserons à la place [Mailpit] (https://mailpit.axllent.org), un outil gratuit conçu pour les développeurs :
- c'est un serveur SMTP qui accepte tous les mails qu'on lui envoie, quels que soient l'expéditeur et le destinataire, mais qui ne les distribue pas : il les garde. On peut donc envoyer des mails à n'importe quelle adresse sans risque d'importuner qui que ce soit ;
- il offre une interface web qui permet de lire les mails reçus ;
- il offre aussi un serveur POP3, que nous utiliserons pour lire les mails par programme ;
- c'est un unique exécutable, sans installation .
[Mailpit] est téléchargeable sur la page https://github.com/axllent/mailpit/releases. Sous Windows, on prend le fichier [mailpit-windows-amd64.zip] de la version la plus récente et on copie le fichier [mailpit.exe] qu'il contient dans le dossier [inet/mailpit] du code du cours. Ce dossier contient déjà les fichiers suivants :
| mailpit
├── mailpit.bat
├── mailpit-securise.bat
├── pop3-users.txt
├── relay.yml
└── smtp-users.txt
|
Le fichier [pop3-users.txt] définit les utilisateurs du serveur POP3 de [Mailpit] sous la forme [utilisateur:mot de passe]. Il n'y en a qu'un :
Le fichier [mailpit.bat] lance [Mailpit] avec ce fichier d'utilisateurs :
| @echo off
rem instance principale de Mailpit : SMTP sur le port 1025, POP3 sur le port 1110, interface web sur le port 8025
"%~dp0mailpit.exe" --pop3-auth-file "%~dp0pop3-users.txt"
|
- [%~dp0] désigne le dossier du fichier [.bat] : on peut donc lancer [mailpit.bat] depuis n'importe quel dossier, par exemple en double-cliquant dessus dans l'explorateur Windows ;
- l'option [--pop3-auth-file] active le serveur POP3 de [Mailpit] .
On peut aussi taper directement la commande dans un terminal ouvert dans le dossier [inet/mailpit] : [.\mailpit.exe --pop3-auth-file pop3-users.txt]. [Mailpit] affiche alors des lignes de logs indiquant qu'il offre trois services :
- un serveur SMTP sur le port 1025 (le port standard du protocole SMTP est le port 25, mais les ports inférieurs à 1024 sont souvent réservés ou bloqués) ;
- un serveur POP3 sur le port 1110 (le port standard est le port 110) ;
- une interface web à l'URL [http://localhost:8025] .
Le terminal doit rester ouvert tant qu'on veut utiliser [Mailpit] : la combinaison de touches [Ctrl-C] l'arrête. Les mails reçus sont conservés en mémoire tant que [Mailpit] fonctionne. Nous retrouverons [Mailpit] dans les versions suivantes du serveur de calcul de l'impôt, qui enverront des mails à leur administrateur.
Le second fichier [mailpit-securise.bat] lance une seconde instance de [Mailpit], que nous utiliserons plus loin pour étudier les connexions sécurisées :
| @echo off
rem instance securisee de Mailpit : SMTP avec STARTTLS et authentification obligatoires sur le port 1587,
rem interface web en HTTPS sur le port 8026, relais des messages vers l instance principale (port 1025)
"%~dp0mailpit.exe" --smtp 0.0.0.0:1587 --listen 0.0.0.0:8026 --ui-tls-cert sans:localhost --ui-tls-key sans:localhost --smtp-tls-cert sans:localhost --smtp-tls-key sans:localhost --smtp-require-starttls --smtp-auth-file "%~dp0smtp-users.txt" --smtp-relay-config "%~dp0relay.yml" --smtp-relay-all
|
- [--smtp 0.0.0.0:1587] : le serveur SMTP de cette instance écoute sur le port 1587 ;
- [--listen 0.0.0.0:8026 --ui-tls-cert sans:localhost --ui-tls-key sans:localhost] : son interface web est sur le port 8026 (le port 8025 est pris par la première instance), en HTTPS ;
- [--smtp-tls-cert sans:localhost --smtp-tls-key sans:localhost] : [Mailpit] fabrique lui-même un certificat autosigné pour la machine [localhost]. Ce certificat lui permet de chiffrer les échanges avec ses clients ;
- [--smtp-require-starttls] : les clients doivent chiffrer la connexion (commande STARTTLS, que nous verrons) avant d'envoyer un mail ;
- [--smtp-auth-file smtp-users.txt] : les clients doivent s'authentifier avec l'un des comptes du fichier [smtp-users.txt]. Il n'y en a qu'un, [guest@localhost] avec le mot de passe [guest] ;
- [--smtp-relay-config relay.yml --smtp-relay-all] : les mails reçus sont relayés au serveur SMTP défini dans [relay.yml], c'est-à-dire à la première instance de [Mailpit] (localhost, port 1025) :
| # relais SMTP de l'instance « sécurisée » de Mailpit (port 1587)
# vers l'instance principale (port 1025)
host: localhost
port: 1025
|
Cette seconde instance joue le rôle que jouerait un serveur SMTP public comme celui de Gmail ([smtp.gmail.com], port 587), qui exige lui aussi une connexion chiffrée et une authentification. Nous l'utiliserons avec un compte Gmail le moment venu, pour ceux qui en ont un.
Lançons la première instance de [Mailpit] et ouvrons son interface web [http://localhost:8025] avec un navigateur :

Nous verrons cette page se remplir au fil des exemples.
16.5.3. Le protocole SMTP

Nous allons découvrir le protocole SMTP avec l'environnement suivant :
- le client A sera le client TCP générique [tcp-client.php] ;
- le serveur B sera le serveur SMTP de [Mailpit] ;
- le client A demandera au serveur B de distribuer un courrier à l'utilisateur [guest@localhost] ;
- nous vérifierons avec l'interface web de [Mailpit] que le mail a bien été reçu .
Dans un terminal ouvert dans le dossier [inet/utilitaires], nous lançons le client et nous tapons les commandes du protocole SMTP :
| > php tcp-client.php --quit bye localhost 1025
Client [127.0.0.1:50072] connecté au serveur [localhost:1025]
Tapez vos commandes (bye pour arrêter) :
<-- [220 vm Mailpit ESMTP Service ready]
EHLO localhost
<-- [250-vm greets localhost]
<-- [250-SIZE 52428800]
<-- [250-ENHANCEDSTATUSCODES]
<-- [250-8BITMIME]
<-- [250 SMTPUTF8]
MAIL FROM: <guest@localhost>
<-- [250 2.1.0 Ok]
RCPT TO: <guest@localhost>
<-- [250 2.1.5 Ok]
DATA
<-- [354 Start mail input; end with <CR><LF>.<CR><LF>]
Subject: test
From: guest@localhost
To: guest@localhost
ligne 1
ligne 2
.
<-- [250 2.0.0 Ok: queued as 0d0daBzXhAGBiM8Aw3oxZ7]
QUIT
<-- [221 2.0.0 vm Mailpit ESMTP Service closing transmission channel]
Perte de la connexion avec le serveur...
Fin du client
|
- [php tcp-client.php --quit bye localhost 1025] : on se connecte sur le port 1025 de la machine locale, là où opère le service SMTP de [Mailpit]. L'argument [--quit bye] indique que l'utilisateur quittera le programme en tapant la commande [bye]. Sans cet argument, la commande de fin du programme est [quit]. Or [QUIT] est également une commande du protocole SMTP. Il nous faut donc éviter cette ambiguïté ;
- [220 vm Mailpit ESMTP Service ready] : dès la connexion, le serveur envoie son message de bienvenue. Le code [220] indique que le serveur est prêt. [vm] est le nom de la machine qui exécute [Mailpit] : vous aurez le nom de votre ordinateur ;
- [EHLO localhost] : le client envoie la commande [EHLO nom-de-la-machine-cliente]. Le serveur lui répond par une suite de lignes de la forme [250-xx], la dernière étant de la forme [250 xx] (un espace et non un tiret après le code). Le code [250] indique le succès de la commande envoyée par le client. Les lignes suivantes indiquent les extensions du protocole SMTP que le serveur connaît, par exemple la taille maximale d'un message [SIZE] ;
- [MAIL FROM: <guest@localhost>] : le client indique l'expéditeur du message, et le serveur l'accepte (code 250) ;
- [RCPT TO: <guest@localhost>] : le client indique le destinataire du message. Il pourrait y avoir plusieurs commandes [RCPT TO] pour plusieurs destinataires ;
- [DATA] : cette commande indique au serveur que le client va envoyer le contenu du message. Le serveur répond par le code [354] et indique comment terminer le message ;
- le client envoie ensuite une liste de lignes de texte terminée par une ligne ne contenant qu'un unique point. Le message peut commencer par des lignes [Subject:, From:, To:] pour définir respectivement le sujet du message, l'expéditeur, le destinataire. Ces entêtes doivent être suivis d'une ligne vide, puis vient le texte du message ;
- [250 2.0.0 Ok: queued as ...] : une fois que le serveur a reçu la ligne ne contenant qu'un unique point, il met le message en file d'attente pour le distribuer ;
- [QUIT] : le client indique au serveur qu'il a fini. Le serveur répond par le code [221] puis ferme la connexion, ce que le client détecte .
Vérifions dans l'interface web de [Mailpit] que l'utilisateur [guest@localhost] a bien reçu le message :

On retrouve l'expéditeur [From], le destinataire [To], le sujet [Subject] et le texte du message. L'onglet [Headers] affiche tous les entêtes du message et l'onglet [Raw] le texte brut reçu par [Mailpit].
Notre client [tcp-client.php] a réussi à envoyer le message. Nous avons les bases pour créer un client SMTP écrit en PHP.
16.5.4. Un client SMTP basique écrit en PHP
Nous allons reproduire en PHP ce que nous avons appris précédemment du protocole SMTP.

Le script [inet/smtp/smtp-01.php] est configuré par le fichier JSON [config-smtp-01.json] suivant :
| {
"mail to localhost via localhost": {
"smtp-server": "localhost",
"smtp-port": 1025,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "to localhost via localhost",
"message": "ligne 1\nligne 2\nligne 3"
},
"mail to gmail via localhost": {
"smtp-server": "localhost",
"smtp-port": 1025,
"from": "guest@localhost",
"to": "php8parlexemple@gmail.com",
"subject": "to gmail via localhost",
"message": "ligne 1\nligne 2\nligne 3"
},
"mail via a secure server": {
"smtp-server": "localhost",
"smtp-port": 1587,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "via a secure server",
"message": "ligne 1\nligne 2\nligne 3"
}
}
|
[config-smtp-01.json] est un dictionnaire dont chaque élément est de la forme [nom => infos]. La valeur [infos] est elle-même un dictionnaire avec les clés et valeurs suivantes :
- [smtp-server] : le nom du serveur SMTP à utiliser ;
- [smtp-port] : le n° du port du service SMTP ;
- [from] : l'expéditeur du message ;
- [to] : le destinataire du message ;
- [subject] : le sujet du message ;
- [message] : le message à envoyer .
Il y a trois envois :
- le 1er utilise le serveur SMTP de [Mailpit] pour envoyer un mail à l'utilisateur [guest@localhost] ;
- le 2e utilise le même serveur pour envoyer un mail à une adresse Gmail. [Mailpit] gardera ce mail sans le distribuer ;
- le 3e utilise la seconde instance de [Mailpit], celle du port 1587, qui exige une connexion chiffrée. Il faut donc avoir lancé [mailpit-securise.bat] .
Le code [smtp-01.php] du client SMTP est le suivant :
| <?php
declare(strict_types=1);
// client SMTP (Simple Mail Transfer Protocol) permettant d'envoyer un message
// protocole de communication SMTP client-serveur
// -> client se connecte sur le port du serveur SMTP (25, 587, 1025...)
// <- serveur lui envoie un message de bienvenue
// -> client envoie la commande EHLO nom de sa machine
// <- serveur répond OK ou non
// -> client envoie la commande MAIL FROM: <expéditeur>
// <- serveur répond OK ou non
// -> client envoie la commande RCPT TO: <destinataire>
// <- serveur répond OK ou non
// -> client envoie la commande DATA
// <- serveur répond OK ou non
// -> client envoie toutes les lignes de son message et termine avec une ligne contenant le
// seul caractère .
// <- serveur répond OK ou non
// -> client envoie la commande QUIT
// <- serveur répond OK ou non
// les réponses du serveur ont la forme xxx texte où xxx est un nombre à 3 chiffres. Tout
// nombre xxx >= 400 signale une erreur.
// La réponse peut comporter plusieurs lignes de la forme xxx-texte, sauf la dernière
// qui a la forme xxx texte (espace après le code)
// les lignes de texte échangées doivent se terminer par les caractères CR (13) et LF (10)
// les paramètres de l'envoi des courriers
const CONFIG_FILE_NAME = "config-smtp-01.json";
// on récupère la configuration
$mails = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// envoi des courriers
foreach ($mails as $name => $infos) {
// suivi
print "Envoi du mail [$name]\n";
// envoi du courrier
$résultat = sendmail($infos, verbose: true);
// affichage résultat
print "$résultat\n";
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function sendmail(array $infos, bool $verbose = true): string
{
// envoie le message décrit par [$infos]
// si $verbose=true, fait un suivi des échanges client-serveur
//
// le nom de la machine cliente
$client = gethostname();
// ouverture d'une connexion avec le serveur SMTP
$connexion = @stream_socket_client("tcp://{$infos["smtp-server"]}:{$infos["smtp-port"]}", $numErreur, $erreur);
if ($connexion === false) {
return "Échec de la connexion au serveur [{$infos["smtp-server"]}:{$infos["smtp-port"]}] : $erreur";
}
// $connexion représente un flux de communication bidirectionnel
// entre le client (ce programme) et le serveur SMTP contacté
try {
// après la connexion, le serveur envoie un message de bienvenue qu'on lit
sendCommand($connexion, "", $verbose);
// commande EHLO
sendCommand($connexion, "EHLO $client", $verbose);
// commande MAIL FROM:
sendCommand($connexion, "MAIL FROM: <{$infos["from"]}>", $verbose);
// commande RCPT TO:
sendCommand($connexion, "RCPT TO: <{$infos["to"]}>", $verbose);
// commande DATA
sendCommand($connexion, "DATA", $verbose);
// le message : des entêtes (From, To, Subject), une ligne vide, le texte
// et une ligne réduite à un point
// les fins de ligne du texte doivent être \r\n
$texte = str_replace("\n", "\r\n", $infos["message"]);
// une ligne du texte qui commence par un point doit commencer par deux points
$texte = preg_replace('/^\./m', '..', $texte);
$data = "From: {$infos["from"]}\r\nTo: {$infos["to"]}\r\nSubject: {$infos["subject"]}\r\n\r\n$texte\r\n.";
sendCommand($connexion, $data, $verbose);
// commande QUIT
sendCommand($connexion, "QUIT", $verbose);
return "Message envoyé";
} catch (RuntimeException $ex) {
// le serveur a signalé une erreur
return "Erreur : " . $ex->getMessage();
} finally {
// dans tous les cas, on ferme la connexion
fclose($connexion);
}
}
// --------------------------------------------------------------------------
function sendCommand($connexion, string $commande, bool $verbose): void
{
// envoie $commande dans le canal $connexion et lit la réponse du serveur
// mode verbeux si $verbose=true
// lance une RuntimeException si le serveur signale une erreur
//
// envoi de la commande si elle n'est pas vide
if ($commande !== "") {
fwrite($connexion, "$commande\r\n");
if ($verbose) {
affiche($commande, 1);
}
}
// lecture de la réponse : des lignes [xxx-texte] terminées par une ligne [xxx texte]
do {
$réponse = fgets($connexion);
if ($réponse === false) {
throw new RuntimeException("le serveur a fermé la connexion");
}
if ($verbose) {
affiche($réponse, 2);
}
} while (substr($réponse, 3, 1) === "-");
// code de la réponse
$code = (int) substr($réponse, 0, 3);
// erreur renvoyée par le serveur ?
if ($code >= 400) {
throw new RuntimeException(rtrim(substr($réponse, 4)));
}
}
// --------------------------------------------------------------------------
function affiche(string $échange, int $sens): void
{
// affiche $échange à l'écran
// si $sens=1 affiche --> [$échange] (avec des fins de ligne \n à la place de \r\n)
// si $sens=2 affiche <-- [$échange] sans les caractères de fin de ligne \r\n
print match ($sens) {
1 => "--> [" . str_replace("\r\n", "\n", $échange) . "]\n",
2 => "<-- [" . rtrim($échange, "\r\n") . "]\n",
};
}
|
Commentaires :
- ligne 32 : on exploite le fichier de configuration. Le 2e paramètre [true] de [json_decode] demande des tableaux associatifs plutôt que des objets ;
- ligne 35 : on boucle sur les éléments du dictionnaire [$mails]. Chaque élément est de la forme [name => infos] où [name] est un nom quelconque et [infos] un dictionnaire contenant les informations nécessaires à l'envoi d'un mail ;
- ligne 39 : l'envoi du mail est assuré par la fonction [sendmail] qui admet deux paramètres :
- $infos : le dictionnaire contenant les informations nécessaires à l'envoi ;
- $verbose : un booléen indiquant si les échanges client / serveur doivent être ou non affichés sur la console. On notera l'argument nommé [verbose: true] (PHP 8.0) qui rend l'appel plus lisible qu'un simple [true] ;
- ligne 53 : le nom de la machine cliente, utilisé par la commande EHLO ;
- ligne 55 : ouverture d'une connexion avec le serveur SMTP. Le cours PHP 7 utilisait la fonction [fsockopen], toujours disponible. [stream_socket_client] est plus générale : c'est elle que nous avons utilisée dans [tcp-client.php] ;
- lignes 61-86 : la fonction [sendmail] envoie les différentes commandes que doit envoyer un client SMTP : lecture du message de bienvenue, EHLO, MAIL FROM, RCPT TO, DATA, le message, QUIT. Chaque commande est envoyée par la fonction [sendCommand] qui lance une exception [RuntimeException] si le serveur répond par un code d'erreur. Toutes les commandes sont donc dans un bloc [try] : la première erreur nous fait passer dans le bloc [catch] qui rend le message d'erreur. Le bloc [finally] est exécuté dans tous les cas : c'est là qu'on ferme la connexion. Le code est ainsi bien plus court que celui du cours PHP 7, qui testait le résultat de chaque commande ;
- lignes 75-78 : préparation du message. Les lignes du message doivent se terminer par \r\n. Si une ligne du texte ne contient qu'un point, le serveur croira que le message est terminé. Pour éviter cela, le protocole SMTP demande que le client double le point qui commence une ligne : le serveur enlèvera ce point supplémentaire. L'expression régulière [/^\./m] (option m : multiligne) désigne un point en début de ligne ;
- ligne 93 : la fonction [sendCommand] est chargée d'envoyer les commandes du client au serveur SMTP et de lire la réponse. Elle admet trois paramètres :
- [$connexion] : la connexion qui relie le client au serveur ;
- [$commande] : la commande à envoyer. Si elle est vide, rien n'est envoyé : on se contente de lire une réponse (cas du message de bienvenue) ;
- [$verbose] : si true alors les échanges client / serveur sont affichés sur la console ;
- lignes 107-115 : lecture de l'ensemble des lignes de la réponse. Chaque ligne a la forme [XXX-YYY] où XXX est un code numérique, sauf la dernière ligne de la réponse qui a la forme [XXX YYY] (absence du caractère -). La boucle [do ... while] s'arrête donc lorsque le 4e caractère de la ligne n'est pas un tiret ;
- ligne 119 : si le code numérique XXX est supérieur ou égal à 400, alors le serveur a renvoyé une erreur (les codes 4xx signalent des erreurs temporaires, les codes 5xx des erreurs définitives) ;
- ligne 125 : la fonction [affiche] utilise une expression [match] pour choisir le texte à afficher selon le sens de l'échange .
L'exécution du script donne les résultats console suivants :
| > php smtp-01.php
Envoi du mail [mail to localhost via localhost]
<-- [220 vm Mailpit ESMTP Service ready]
--> [EHLO vm]
<-- [250-vm greets vm]
<-- [250-SIZE 52428800]
<-- [250-ENHANCEDSTATUSCODES]
<-- [250-8BITMIME]
<-- [250 SMTPUTF8]
--> [MAIL FROM: <guest@localhost>]
<-- [250 2.1.0 Ok]
--> [RCPT TO: <guest@localhost>]
<-- [250 2.1.5 Ok]
--> [DATA]
<-- [354 Start mail input; end with <CR><LF>.<CR><LF>]
--> [From: guest@localhost
To: guest@localhost
Subject: to localhost via localhost
ligne 1
ligne 2
ligne 3
.]
<-- [250 2.0.0 Ok: queued as 5SoJJqMUYg3ZrkBqoVKLQo]
--> [QUIT]
<-- [221 2.0.0 vm Mailpit ESMTP Service closing transmission channel]
Message envoyé
Envoi du mail [mail to gmail via localhost]
<-- [220 vm Mailpit ESMTP Service ready]
--> [EHLO vm]
<-- [250-vm greets vm]
<-- [250-SIZE 52428800]
<-- [250-ENHANCEDSTATUSCODES]
<-- [250-8BITMIME]
<-- [250 SMTPUTF8]
--> [MAIL FROM: <guest@localhost>]
<-- [250 2.1.0 Ok]
--> [RCPT TO: <php8parlexemple@gmail.com>]
<-- [250 2.1.5 Ok]
--> [DATA]
<-- [354 Start mail input; end with <CR><LF>.<CR><LF>]
--> [From: guest@localhost
To: php8parlexemple@gmail.com
Subject: to gmail via localhost
ligne 1
ligne 2
ligne 3
.]
<-- [250 2.0.0 Ok: queued as 78JcBpdQn6ACCrBcnniPz3]
--> [QUIT]
<-- [221 2.0.0 vm Mailpit ESMTP Service closing transmission channel]
Message envoyé
Envoi du mail [mail via a secure server]
<-- [220 vm Mailpit ESMTP Service ready]
--> [EHLO vm]
<-- [250-vm greets vm]
<-- [250-SIZE 52428800]
<-- [250-STARTTLS]
<-- [250-AUTH LOGIN PLAIN]
<-- [250-ENHANCEDSTATUSCODES]
<-- [250-8BITMIME]
<-- [250 SMTPUTF8]
--> [MAIL FROM: <guest@localhost>]
<-- [530 5.7.0 Must issue a STARTTLS command first]
Erreur : 5.7.0 Must issue a STARTTLS command first
Terminé
|
- les deux premiers envois se passent bien ;
- le troisième échoue : après la commande [MAIL FROM], le serveur du port 1587 envoie un code d'erreur 530 avec le message [Must issue a STARTTLS command first]. On notera que dans sa réponse à la commande EHLO, il annonçait l'extension [STARTTLS] et l'authentification [AUTH LOGIN PLAIN]. Le client SMTP doit au préalable chiffrer la connexion (commande STARTTLS) puis s'authentifier (commande AUTH). Notre client ne l'a pas fait et est donc refusé. Le serveur SMTP de Gmail ([smtp.gmail.com], port 587) répond exactement de la même façon .
L'interface web de [Mailpit] montre les mails reçus. On remarquera que le mail destiné à [php8parlexemple@gmail.com] est bien là : [Mailpit] ne l'a pas transmis à Gmail.
16.5.5. Un second client SMTP écrit avec la bibliothèque [Symfony Mailer]
Le client précédent offre au moins deux insuffisances :
- il ne sait pas utiliser une connexion sécurisée si le serveur la réclame ;
- il ne sait pas joindre des pièces jointes au message .
Dans notre nouveau script, nous allons utiliser la bibliothèque [Symfony Mailer] (https://symfony.com/doc/current/mailer.html). Le cours PHP 7 utilisait la bibliothèque [SwiftMailer], qui n'est plus maintenue depuis 2021 : [Symfony Mailer] est son successeur, écrit par la même équipe. La bibliothèque a été installée avec les autres dépendances du cours par la commande [composer install] (cf. le fichier [composer.json] à la racine du dossier [php8-oct-2026]) et se trouve dans le dossier [vendor/symfony/mailer]. Elle utilise le composant [symfony/mime], qui sait fabriquer des messages de toute forme.
Le script [inet/smtp/smtp-02.php] utilisera le fichier de configuration JSON [config-smtp-02.json] suivant :
| {
"mail to localhost via localhost": {
"smtp-server": "localhost",
"smtp-port": 1025,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "test-localhost",
"message": "ligne 1\nligne 2\nligne 3",
"tls": false,
"attachments": [
"/attachments/Hello from Symfony Mailer.docx",
"/attachments/Hello from Symfony Mailer.pdf",
"/attachments/Hello from Symfony Mailer.odt",
"/attachments/Cours-Tutoriels-Serge-Tahé-1568x268.png",
"/attachments/test-localhost.eml"
]
},
"mail to localhost via a secure server": {
"smtp-server": "localhost",
"smtp-port": 1587,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "test-secure-server",
"message": "ligne 1\nligne 2\nligne 3",
"tls": true,
"user": "guest@localhost",
"password": "guest",
"verify-peer": false,
"attachments": [
"/attachments/Hello from Symfony Mailer.pdf"
]
},
"mail to gmail via gmail": {
"enabled": false,
"smtp-server": "smtp.gmail.com",
"smtp-port": 587,
"from": "php8parlexemple@gmail.com",
"to": "php8parlexemple@gmail.com",
"subject": "test-gmail-via-gmail",
"message": "ligne 1\nligne 2\nligne 3",
"tls": true,
"user": "php8parlexemple@gmail.com",
"password": "mot de passe d'application",
"verify-peer": true,
"attachments": [
"/attachments/Hello from Symfony Mailer.docx",
"/attachments/Hello from Symfony Mailer.pdf",
"/attachments/Hello from Symfony Mailer.odt",
"/attachments/Cours-Tutoriels-Serge-Tahé-1568x268.png",
"/attachments/test-localhost.eml"
]
}
}
|
On retrouve les mêmes rubriques que dans le fichier [config-smtp-01.json] avec des rubriques supplémentaires :
- [tls] : à [true] indique qu'il faut utiliser une connexion sécurisée et authentifiée avec le serveur SMTP. Dans ce cas, il faut ajouter les rubriques :
- [user] : le nom de l'utilisateur qui authentifie la connexion ;
- [password] : son mot de passe ;
- [verify-peer] : faut-il vérifier le certificat envoyé par le serveur ? Il faut le faire avec un serveur public. Avec notre serveur de test, dont le certificat est autosigné, il faut mettre [false] ;
- [attachments] : donne les noms des fichiers à joindre au mail. Ils sont dans le dossier [smtp/attachments] ;
- [enabled] : à [false] désactive l'envoi. C'est le cas du 3e envoi, qui utilise le serveur SMTP de Gmail .
Pour utiliser le serveur SMTP de Gmail, il faut un compte Gmail (remplacez [php8parlexemple@gmail.com] par votre adresse) sur lequel la validation en deux étapes est activée, et créer pour lui un « mot de passe d'application » dans la rubrique [Sécurité] de la gestion du compte Google. Ce mot de passe de 16 lettres remplace le mot de passe du compte dans les applications comme la nôtre. On le met dans la rubrique [password] et on passe [enabled] à [true].
Le code du script [smtp-02.php] est le suivant :
| <?php
declare(strict_types=1);
// client SMTP (Simple Mail Transfer Protocol) écrit avec la bibliothèque [Symfony Mailer]
// sait utiliser une connexion sécurisée et joindre des fichiers au message
// dépendances
require_once __DIR__ . "/../../../vendor/autoload.php";
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
// les paramètres de l'envoi des courriers : le fichier de configuration
// peut être passé en argument (php smtp-02.php config.json)
$configFileName = $argv[1] ?? "config-smtp-02.json";
// on récupère la configuration
$mails = json_decode(file_get_contents($configFileName), true);
// envoi des courriers
foreach ($mails as $name => $infos) {
// envoi désactivé dans la configuration ?
if (($infos["enabled"] ?? true) === false) {
print "Envoi du mail [$name] désactivé\n";
continue;
}
// suivi
print "Envoi du mail [$name]\n";
// envoi du courrier
$résultat = sendmail($name, $infos);
// affichage résultat
print "$résultat\n";
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function sendmail(string $name, array $infos): string
{
// envoie $infos["message"] au serveur SMTP $infos["smtp-server"] sur le port $infos["smtp-port"]
// si $infos["tls"] est vrai, la connexion est sécurisée et authentifiée
// le mail est envoyé de la part de $infos["from"]
// pour le destinataire $infos["to"]
// les documents $infos["attachments"] sont joints au message
// le message a le sujet $infos["subject"]
//
try {
// création du message
$email = (new Email())
// expéditeur
->from($infos["from"])
// destinataire (il peut y en avoir plusieurs : to(a, b, c), cc(...), bcc(...))
->to($infos["to"])
// sujet du message
->subject($infos["subject"])
// texte du message
->text($infos["message"])
// variante HTML du texte
->html("<b>" . str_replace("\n", "<br/>", $infos["message"]) . "</b>");
// pièces jointes
foreach ($infos["attachments"] as $attachment) {
// chemin de la pièce jointe
$fileName = __DIR__ . $attachment;
// on vérifie que le fichier existe
if (file_exists($fileName)) {
// on joint le document au message
$email->attachFromPath($fileName);
} else {
print "La pièce jointe [$fileName] n'existe pas\n";
}
}
// le transport du message : un DSN (Data Source Name) de la forme
// smtp://utilisateur:motdepasse@serveur:port?options
$dsn = "smtp://{$infos["smtp-server"]}:{$infos["smtp-port"]}";
if ($infos["tls"]) {
// connexion sécurisée (STARTTLS) et authentifiée
$verifyPeer = ($infos["verify-peer"] ?? true) ? 1 : 0;
$dsn = sprintf("smtp://%s:%s@%s:%d?require_tls=1&verify_peer=%d",
rawurlencode($infos["user"]), rawurlencode($infos["password"]),
$infos["smtp-server"], $infos["smtp-port"], $verifyPeer);
}
// le gestionnaire de l'envoi
$mailer = new Mailer(Transport::fromDsn($dsn));
// envoi du message
$mailer->send($email);
// fin
return "Message [$name] envoyé";
} catch (Throwable $ex) {
// erreur
return "Erreur lors de l'envoi du message [$name] : " . $ex->getMessage();
}
}
|
Commentaires :
- ligne 9 : nous chargeons le fichier [vendor/autoload.php] créé par Composer. Ce fichier permet de charger les fichiers de définition des classes des bibliothèques dès le 1er usage de ces classes. Il nous évite de mettre autant de [require] que de classes et d'interfaces de [Symfony Mailer] que nous allons utiliser. Le chemin est relatif au dossier du script ([__DIR__]) : le script fonctionne quel que soit l'emplacement du dossier [php8-oct-2026] ;
- lignes 11-13 : les classes de [Symfony Mailer] sont dans des espaces de noms. Les instructions [use] nous permettent de les désigner par leur nom court ;
- ligne 17 : le fichier de configuration peut être passé en argument du script ; à défaut, c'est [config-smtp-02.json] ;
- ligne 25 : un envoi est désactivé si sa rubrique [enabled] vaut [false]. L'opérateur [??] rend [true] si la rubrique est absente ;
- ligne 40 : la nouvelle fonction [sendmail] qui a deux paramètres :
- [$name] qui sert à différencier les messages entre eux ;
- [$infos] : les informations nécessaires pour envoyer le message à son destinataire ;
- lignes 51-61 : nous définissons le message à l'aide de la classe [Email]. Ses méthodes rendent l'objet lui-même, ce qui permet de les enchaîner :
- [from] fixe l'expéditeur du message ;
- [to] fixe le destinataire du message ;
- [subject] fixe le sujet du message ;
- [text] fixe le corps du message ;
- [html] fixe une variante HTML du message. Ici, nous changeons les marques de fin de ligne en balises HTML [<br/>] et nous mettons le tout en gras. Lorsque le message a des variantes, les lecteurs de courrier affichent la variante préférée de l'utilisateur ;
- lignes 63-72 : la méthode [attachFromPath] joint un fichier au message. Le type MIME du fichier (application/pdf...) est déduit de son extension ;
- lignes 76-82 : une fois le message à envoyer défini, il faut définir comment l'envoyer. [Symfony Mailer] décrit le transport du message par une chaîne appelée DSN (Data Source Name), de la forme [smtp://utilisateur:motdepasse@serveur:port?options]. Pour une connexion sécurisée, on ajoute l'utilisateur et son mot de passe, codés avec [rawurlencode] (le caractère @ de [guest@localhost] devient %40), et deux options : [require_tls=1] exige le chiffrement de la connexion et [verify_peer=0] désactive la vérification du certificat du serveur. On notera que [Symfony Mailer] utilise de lui-même la commande STARTTLS lorsque le serveur l'annonce ;
- ligne 85 : c'est la classe [Mailer] qui envoie les messages. On doit lui passer le transport choisi, créé à partir du DSN par la méthode statique [Transport::fromDsn] ;
- ligne 87 : le message est envoyé ;
- ligne 90 : la bibliothèque lance une exception dès que quelque chose ne se passe pas bien .
Lorsqu'on exécute le script [smtp-02.php] (les deux instances de [Mailpit] étant lancées), on obtient les résultats console suivants :
| > php smtp-02.php
Envoi du mail [mail to localhost via localhost]
Message [mail to localhost via localhost] envoyé
Envoi du mail [mail to localhost via a secure server]
Message [mail to localhost via a secure server] envoyé
Envoi du mail [mail to gmail via gmail] désactivé
Terminé
|
L'interface web de [Mailpit] montre les deux messages. Le message [test-secure-server] est passé par l'instance sécurisée, qui l'a relayé vers l'instance principale. Voici le message [test-localhost] :

- en haut à droite, le badge [Attachments (5)] indique les cinq pièces jointes, dont on voit les vignettes en bas de la page ;
- l'onglet [HTML] affiche la variante HTML du message (en gras), l'onglet [Text] sa variante texte .
L'onglet [Raw] montre le texte brut du message reçu. Le voici, dont on a retiré les lignes qui codent le contenu des pièces jointes :
| Return-Path: <guest@localhost>
Received: from [127.0.0.1] (localhost [127.0.0.1])
by vm (Mailpit) with SMTP
for <guest@localhost>; Thu, 1 Oct 2026 17:14:24 +0200 (CEST)
From: guest@localhost
To: guest@localhost
Subject: test-localhost
Message-ID: <d17f753f0db420a353457b730f2cfdec@localhost>
MIME-Version: 1.0
Date: Thu, 01 Oct 2026 17:14:24 +0200
Content-Type: multipart/mixed; boundary=jM982j4q
--jM982j4q
Content-Type: multipart/alternative; boundary=LUEVyqZR
--LUEVyqZR
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
ligne 1
ligne 2
ligne 3
--LUEVyqZR
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<b>ligne 1<br/>ligne 2<br/>ligne 3</b>
--LUEVyqZR--
--jM982j4q
Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document;
name="Hello from Symfony Mailer.docx"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name="Hello from Symfony Mailer.docx";
filename="Hello from Symfony Mailer.docx"
--jM982j4q
Content-Type: application/pdf; name="Hello from Symfony Mailer.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name="Hello from Symfony Mailer.pdf";
filename="Hello from Symfony Mailer.pdf"
--jM982j4q
Content-Type: application/vnd.oasis.opendocument.text;
name="Hello from Symfony Mailer.odt"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name="Hello from Symfony Mailer.odt";
filename="Hello from Symfony Mailer.odt"
--jM982j4q
Content-Type: image/png;
name="=?utf-8?Q?Cours-Tutoriels-Serge-Tah=C3=A9-1568x268=2Epng?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment;
name*=utf-8''Cours-Tutoriels-Serge-Tah%C3%A9-1568x268.png;
filename*=utf-8''Cours-Tutoriels-Serge-Tah%C3%A9-1568x268.png
--jM982j4q
Content-Type: message/rfc822; name=test-localhost.eml
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name=test-localhost.eml;
filename=test-localhost.eml
--jM982j4q--
|
- [Return-Path] et [Received] : ces entêtes ont été ajoutés par [Mailpit] à la réception du message. Chaque serveur traversé par un message ajoute un entête [Received] ;
- [From], [To], [Subject] : l'expéditeur, le destinataire et le sujet ;
- [Content-Type: multipart/mixed; boundary=...] : le message est formé de plusieurs parties, séparées par des lignes [--boundary]. La dernière est suivie de la ligne [--boundary--]. C'est le format MIME (Multipurpose Internet Mail Extensions) ;
- la 1re partie est elle-même de type [multipart/alternative] : elle contient deux variantes du message, en [text/plain] et en [text/html]. Elles sont codées en [quoted-printable], un codage qui laisse lisibles les caractères ASCII et code les autres (é devient =C3=A9) ;
- viennent ensuite les cinq pièces jointes ([Content-Disposition: attachment]), codées en [base64], un codage qui transforme n'importe quel contenu binaire en lignes de caractères ASCII ;
- le nom du fichier [Cours-Tutoriels-Serge-Tahé-1568x268.png] contient le caractère [é]. Il est codé de deux façons : [name="=?utf-8?Q?...?="] (codage dit RFC 2047) et [filename*=utf-8''...%C3%A9...] (codage dit RFC 2231). Nous les retrouverons en lisant les mails ;
- la dernière pièce jointe, de type [message/rfc822], est elle-même un mail : le fichier [test-localhost.eml], qui a lui aussi des pièces jointes .
Pour finir, voyons deux erreurs classiques. Le fichier [config-smtp-02-erreurs.json] décrit deux envois vers l'instance sécurisée : le premier vérifie le certificat du serveur ([verify-peer] à [true]), le second utilise un mauvais mot de passe :
| {
"certificat du serveur vérifié": {
"smtp-server": "localhost",
"smtp-port": 1587,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "test-erreurs",
"message": "ligne 1\nligne 2\nligne 3",
"tls": true,
"user": "guest@localhost",
"password": "guest",
"verify-peer": true,
"attachments": []
},
"mauvais mot de passe": {
"smtp-server": "localhost",
"smtp-port": 1587,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "test-erreurs",
"message": "ligne 1\nligne 2\nligne 3",
"tls": true,
"user": "guest@localhost",
"password": "xx",
"verify-peer": false,
"attachments": []
}
}
|
Exécutons le script avec ce fichier de configuration, avec la commande [php smtp-02.php config-smtp-02-erreurs.json] :
| > php smtp-02.php config-smtp-02-erreurs.json
Envoi du mail [certificat du serveur vérifié]
Erreur lors de l'envoi du message [certificat du serveur vérifié] : Unable to connect with STARTTLS: stream_socket_enable_crypto(): SSL operation failed with code 1. OpenSSL Error messages:
error:0A000086:SSL routines::certificate verify failed
Envoi du mail [mauvais mot de passe]
Erreur lors de l'envoi du message [mauvais mot de passe] : Failed to authenticate on SMTP server with username "guest@localhost" using the following authenticators: "LOGIN", "PLAIN". Authenticator "LOGIN" returned "Expected response code "235" but got code "535", with message "535 5.7.8 Authentication credentials invalid".". Authenticator "PLAIN" returned "Expected response code "235" but got code "535", with message "535 5.7.8 Authentication credentials invalid".".
Terminé
|
- 1er envoi : le certificat autosigné de [Mailpit] n'a pas été émis par une autorité de certification connue. Sa vérification échoue et le client refuse de poursuivre : c'est ce qui protège un client de l'usurpation d'identité d'un serveur. Avec [smtp.gmail.com], dont le certificat est en règle, la vérification réussit ;
- 2e envoi : le serveur refuse l'authentification avec le code d'erreur 535 .
16.6. Les protocoles POP3 (Post Office Protocol) et IMAP (Internet Message Access Protocol)
16.6.1. Introduction
Pour lire les mails entreposés dans un serveur de mails, deux protocoles existent :
- le protocole POP3 (Post Office Protocol), historiquement le 1er protocole mais peu utilisé maintenant ;
- le protocole IMAP (Internet Message Access Protocol), protocole plus récent que POP3 et le plus utilisé actuellement .
Pour découvrir ces protocoles, nous allons utiliser l'architecture suivante :

- [Serveur B] sera :
- pour POP3, le serveur POP3 de [Mailpit] (port 1110) ;
- pour IMAP, un serveur IMAP. Ce peut être celui de Gmail ([imap.gmail.com], port 993), avec le compte Gmail et le mot de passe d'application évoqués pour le protocole SMTP. Les résultats présentés ici ont été obtenus avec un serveur IMAP local, le serveur open source [Dovecot] (https://www.dovecot.org), sur lequel existe un utilisateur [guest@localhost] de mot de passe [guest]. Nous avons déposé dans sa boîte à lettres INBOX les mails envoyés par nos scripts SMTP. Avec Gmail, vous obtiendrez des résultats de même forme ;
- [Client A] sera un client POP3 / IMAP de diverses formes :
- le client [tcp-client.php] pour découvrir les protocoles POP3 et IMAP ;
- un script PHP rejouant le protocole POP3 découvert avec [tcp-client.php] ;
- des scripts PHP utilisant des classes POP3 et IMAP que nous écrirons, puis la bibliothèque [zbateson/mail-mime-parser] pour analyser les messages .
Le cours PHP 7 utilisait les fonctions [imap_*] de l'extension [imap] de PHP, qui savaient parler aussi bien IMAP que POP3. Cette extension, qui reposait sur une bibliothèque C abandonnée depuis des années, a été retirée de PHP 8.4. Nous allons donc écrire nous-mêmes, avec les fonctions de sockets de PHP, les clients POP3 et IMAP dont nous avons besoin. C'est l'occasion de bien comprendre ces deux protocoles.
16.6.2. Découverte du protocole POP3
Tout d'abord, nous utilisons le script [smtp-01.php] pour envoyer deux mails : l'un à [guest@localhost], l'autre à [php8parlexemple@gmail.com] (le 3e envoi échoue, nous l'avons vu). Le serveur POP3 de [Mailpit] présente tous les mails reçus par [Mailpit] dans une unique boîte à lettres, quels que soient leurs destinataires. Pour la lire, il faut s'identifier avec un utilisateur du fichier [pop3-users.txt], ici [guest] avec le mot de passe [guest].
Voyons avec le client [tcp-client.php] comment on peut lire cette boîte à lettres :
| > php tcp-client.php --quit bye localhost 1110
Client [127.0.0.1:47492] connecté au serveur [localhost:1110]
Tapez vos commandes (bye pour arrêter) :
<-- [+OK Mailpit POP3 server]
USER guest
<-- [+OK]
PASS guest
<-- [+OK signed in]
LIST
<-- [+OK 2 messages (688 octets)]
<-- [1 352]
<-- [2 336]
<-- [.]
RETR 1
<-- [+OK 352 octets]
<-- [Message-ID: <73n0AqT1QxqOYmrcWH5gK7@mailpit>]
<-- [Return-Path: <guest@localhost>]
<-- [Received: from vm (localhost [127.0.0.1])]
<-- [ by vm (Mailpit) with SMTP]
<-- [ for <php8parlexemple@gmail.com>; Thu, 1 Oct 2026 17:14:31 +0200 (CEST)]
<-- [From: guest@localhost]
<-- [To: php8parlexemple@gmail.com]
<-- [Subject: to gmail via localhost]
<-- []
<-- [ligne 1]
<-- [ligne 2]
<-- [ligne 3]
<-- []
<-- [.]
DELE 1
<-- [+OK message marked for deletion]
LIST
<-- [+OK 1 messages (336 octets)]
<-- [2 336]
<-- [.]
DELE 2
<-- [+OK message marked for deletion]
LIST
<-- [+OK 0 messages (0 octets)]
<-- [.]
QUIT
<-- [+OK goodbye]
Perte de la connexion avec le serveur...
Fin du client
|
- [php tcp-client.php --quit bye localhost 1110] : le serveur POP3 travaille généralement avec le port 110. Ici, c'est le port 1110 de [Mailpit] ;
- [USER guest] : la commande [USER] sert à définir l'utilisateur dont on veut lire la boîte à lettres ;
- [PASS guest] : la commande [PASS] sert à définir son mot de passe ;
- [LIST] : la commande [LIST] demande la liste des messages présents dans la boîte à lettres de l'utilisateur. Le serveur répond par le nombre de messages et leur taille totale, puis par une ligne [n° taille] par message ;
- [RETR 1] : la commande [RETR] demande à voir le message dont on passe le n°. On retrouve les entêtes [From], [To], [Subject] envoyés par [smtp-01.php], et des entêtes ajoutés par [Mailpit]. [Mailpit] présente les messages du plus récent au plus ancien : le message n° 1 est le dernier reçu ;
- [DELE 1] : la commande [DELE] marque comme « à supprimer » le message dont on passe le n°. Le message n'apparaît plus dans la liste ;
- [QUIT] : la commande [QUIT] indique au serveur qu'on a terminé. C'est à ce moment que le serveur supprime les messages marqués. Les deux messages ont disparu de l'interface web de [Mailpit] .
La réponse du serveur peut prendre plusieurs formes :
- une ligne unique commençant par [+OK] pour indiquer que la commande précédente du client a réussi ;
- une ligne unique commençant par [-ERR] pour indiquer que la commande précédente du client a échoué ;
- plusieurs lignes où :
- la 1re ligne commence par [+OK] ;
- la dernière ligne est constituée d'un unique point .
16.6.3. Un script basique implémentant le protocole POP3

Comme le protocole POP3 a la même structure que le protocole SMTP, le script [inet/imap/pop3-01.php] est un portage du script [smtp-01.php]. Il a le fichier de configuration [config-pop3-01.json] suivant :
| {
"localhost:1110": {
"server": "localhost",
"port": 1110,
"user": "guest",
"password": "guest",
"maxmails": 5
}
}
|
- lignes 3-4 : le serveur POP3 interrogé est le serveur de [Mailpit] ;
- lignes 5-6 : l'utilisateur et son mot de passe ;
- ligne 7 : on lira au plus 5 mails .
Le script [pop3-01.php] est le suivant :
| <?php
declare(strict_types=1);
// client POP3 (Post Office Protocol) permettant de lire les messages d'une boîte à lettres
// protocole de communication POP3 client-serveur
// -> client se connecte sur le port du serveur POP3 (110, 1110...)
// <- serveur lui envoie un message de bienvenue
// -> client envoie la commande USER utilisateur
// <- serveur répond OK ou non
// -> client envoie la commande PASS mot_de_passe
// <- serveur répond OK ou non
// -> client envoie la commande LIST
// <- serveur répond OK ou non. Si OK envoie la liste [n° taille] des messages
// -> client envoie la commande RETR n° pour chacun des messages
// <- serveur répond OK ou non. Si OK envoie le contenu du message demandé
// -> client envoie la commande DELE n° pour supprimer un message
// <- serveur répond OK ou non
// -> client envoie la commande QUIT pour terminer le dialogue avec le serveur
// <- serveur répond OK ou non
// les réponses du serveur ont la forme +OK texte ou -ERR texte
// La réponse peut comporter plusieurs lignes. Alors la dernière est constituée d'un unique point
// les lignes de texte échangées doivent se terminer par les caractères CR (13) et LF (10)
// les paramètres de lecture des boîtes à lettres
const CONFIG_FILE_NAME = "config-pop3-01.json";
// on récupère la configuration
$mailboxes = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// lecture des boîtes à lettres
foreach ($mailboxes as $name => $infos) {
// suivi
print "Lecture de la boîte à lettres [$name]\n";
// lecture de la boîte à lettres
$résultat = readmail($infos, verbose: true);
// affichage résultat
print "$résultat\n";
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function readmail(array $infos, bool $verbose = true): string
{
// lit au plus $infos["maxmails"] messages de la boîte à lettres décrite par [$infos]
// chaque message est supprimé après sa lecture
// si $verbose=true, fait un suivi des échanges client-serveur
//
// ouverture d'une connexion avec le serveur POP3
$connexion = @stream_socket_client("tcp://{$infos["server"]}:{$infos["port"]}", $numErreur, $erreur);
if ($connexion === false) {
return "Échec de la connexion au serveur [{$infos["server"]}:{$infos["port"]}] : $erreur";
}
try {
// après la connexion, le serveur envoie un message de bienvenue qu'on lit
sendCommand($connexion, "", $verbose);
// commande USER
sendCommand($connexion, "USER {$infos["user"]}", $verbose);
// commande PASS
sendCommand($connexion, "PASS {$infos["password"]}", $verbose);
// commande LIST : la 1re ligne de la réponse a la forme [+OK n messages...]
$réponse = sendCommand($connexion, "LIST", $verbose, multiligne: true);
preg_match('/^\+OK (\d+)/', $réponse[0], $champs);
$nbMessages = (int) ($champs[1] ?? 0);
// on boucle sur les messages
for ($i = 1; $i <= min($nbMessages, $infos["maxmails"]); $i++) {
// commande RETR : le message est dans les lignes 1 à n-1 de la réponse
sendCommand($connexion, "RETR $i", $verbose, multiligne: true);
// commande DELE
sendCommand($connexion, "DELE $i", $verbose);
}
// commande QUIT : c'est elle qui fait supprimer les messages marqués par DELE
sendCommand($connexion, "QUIT", $verbose);
return "Lecture terminée";
} catch (RuntimeException $ex) {
// le serveur a signalé une erreur
return "Erreur : " . $ex->getMessage();
} finally {
// dans tous les cas, on ferme la connexion
fclose($connexion);
}
}
// --------------------------------------------------------------------------
function sendCommand($connexion, string $commande, bool $verbose, bool $multiligne = false): array
{
// envoie $commande dans le canal $connexion et rend les lignes de la réponse
// mode verbeux si $verbose=true
// si $multiligne=true, la réponse se termine par une ligne réduite à un point
// lance une RuntimeException si le serveur signale une erreur
//
// envoi de la commande si elle n'est pas vide
if ($commande !== "") {
fwrite($connexion, "$commande\r\n");
if ($verbose) {
affiche($commande, 1);
}
}
// lecture de la 1re ligne de la réponse
$ligne = lireLigne($connexion, $verbose);
// erreur ?
if (str_starts_with($ligne, "-ERR")) {
throw new RuntimeException(substr($ligne, 5));
}
$réponse = [$ligne];
// réponse sur plusieurs lignes ?
if ($multiligne) {
while (($ligne = lireLigne($connexion, $verbose)) !== ".") {
// une ligne du message qui commence par un point a été envoyée
// avec un point supplémentaire : on l'enlève
$réponse[] = str_starts_with($ligne, "..") ? substr($ligne, 1) : $ligne;
}
}
return $réponse;
}
// --------------------------------------------------------------------------
function lireLigne($connexion, bool $verbose): string
{
// lit une ligne envoyée par le serveur et la rend sans les caractères \r\n
$ligne = fgets($connexion);
if ($ligne === false) {
throw new RuntimeException("le serveur a fermé la connexion");
}
$ligne = rtrim($ligne, "\r\n");
if ($verbose) {
affiche($ligne, 2);
}
return $ligne;
}
// --------------------------------------------------------------------------
function affiche(string $échange, int $sens): void
{
// affiche $échange à l'écran
// si $sens=1 affiche --> [$échange]
// si $sens=2 affiche <-- [$échange]
print match ($sens) {
1 => "--> [$échange]\n",
2 => "<-- [$échange]\n",
};
}
|
Commentaires
Comme nous l'avons dit, [pop3-01.php] est un portage du script [smtp-01.php] que nous avons déjà commenté. Nous ne commenterons que les principales différences :
- ligne 44 : la fonction [readmail] est chargée de lire les mails de la boîte à lettres. Les informations pour se connecter à cette boîte à lettres sont dans le dictionnaire [$infos] ;
- ligne 51 : ouverture d'une connexion avec le serveur POP3 ;
- ligne 57 : lecture du message de bienvenue envoyé par le serveur ;
- lignes 59-61 : on envoie les commandes [USER] et [PASS] pour identifier l'utilisateur dont on veut les mails ;
- lignes 63-65 : on envoie la commande [LIST] pour savoir combien il y a de mails dans la boîte à lettres. La fonction [sendCommand] rend désormais les lignes de la réponse. Dans la 1re ligne de sa réponse à la commande LIST, le serveur indique combien il y a de messages. On l'obtient avec une expression régulière. [$champs[1] ?? 0] vaut 0 si l'expression régulière n'a rien trouvé ;
- lignes 67-71 : on boucle sur les messages. Pour chacun d'eux on émet deux commandes :
- RETR i : pour récupérer le message n° i ;
- DELE i : pour le supprimer une fois qu'il a été lu ;
- ligne 74 : on envoie la commande [QUIT] pour dire au serveur qu'on a terminé ;
- ligne 86 : le paramètre [$multiligne] indique si la réponse attendue a plusieurs lignes, la dernière étant constituée d'un unique point. C'est le cas pour les commandes [LIST] et [RETR]. Une erreur est signalée par une 1re ligne commençant par [-ERR] (fonction [str_starts_with] de PHP 8.0) ;
- lignes 110-112 : comme avec SMTP, le serveur double le point qui commence une ligne du message. Le client doit enlever ce point supplémentaire .
On envoie de nouveau deux mails avec [smtp-01.php], puis on exécute [pop3-01.php] :
| > php pop3-01.php
Lecture de la boîte à lettres [localhost:1110]
<-- [+OK Mailpit POP3 server]
--> [USER guest]
<-- [+OK]
--> [PASS guest]
<-- [+OK signed in]
--> [LIST]
<-- [+OK 2 messages (688 octets)]
<-- [1 352]
<-- [2 336]
<-- [.]
--> [RETR 1]
<-- [+OK 352 octets]
<-- [Message-ID: <67n1volg9CfPFES6KhEOf7@mailpit>]
<-- [Return-Path: <guest@localhost>]
<-- [Received: from vm (localhost [127.0.0.1])]
<-- [ by vm (Mailpit) with SMTP]
<-- [ for <php8parlexemple@gmail.com>; Thu, 1 Oct 2026 17:14:39 +0200 (CEST)]
<-- [From: guest@localhost]
<-- [To: php8parlexemple@gmail.com]
<-- [Subject: to gmail via localhost]
<-- []
<-- [ligne 1]
<-- [ligne 2]
<-- [ligne 3]
<-- []
<-- [.]
--> [DELE 1]
<-- [+OK message marked for deletion]
--> [RETR 2]
<-- [+OK 336 octets]
<-- [Message-ID: <6ZFzliytBzUvTLo2fEhfiC@mailpit>]
<-- [Return-Path: <guest@localhost>]
<-- [Received: from vm (localhost [127.0.0.1])]
<-- [ by vm (Mailpit) with SMTP]
<-- [ for <guest@localhost>; Thu, 1 Oct 2026 17:14:39 +0200 (CEST)]
<-- [From: guest@localhost]
<-- [To: guest@localhost]
<-- [Subject: to localhost via localhost]
<-- []
<-- [ligne 1]
<-- [ligne 2]
<-- [ligne 3]
<-- []
<-- [.]
--> [DELE 2]
<-- [+OK message marked for deletion]
--> [QUIT]
<-- [+OK goodbye]
Lecture terminée
Terminé
|
Nous avons là un client POP3 basique auquel il manque certaines capacités :
- la possibilité de dialoguer avec un serveur sécurisé ;
- la possibilité de lire les pièces jointes d'un message .
Avant de les lui donner, découvrons le protocole IMAP.
16.6.4. Découverte du protocole IMAP
Le protocole IMAP est plus riche que le protocole POP3. Le serveur gère plusieurs boîtes à lettres par utilisateur (INBOX, Sent, Trash...) et mémorise l'état des messages : lu, répondu, à supprimer... Ces états sont appelés des drapeaux (flags). Surtout, le client peut demander au serveur la structure d'un message et n'en télécharger qu'une partie.
Chaque commande du client est précédée d'une étiquette (tag) choisie par le client, par exemple [A1]. Le serveur répond par des lignes commençant par une étoile [*], puis par une ligne commençant par l'étiquette de la commande, suivie de [OK] (succès), [NO] (échec) ou [BAD] (commande incorrecte).
Les serveurs IMAP publics n'acceptent que des connexions chiffrées dès leur ouverture, sur le port 993. Avec Gmail, on lancerait le client TCP avec l'option [--tls] : [php tcp-client.php --tls --quit bye imap.gmail.com 993]. Notre serveur local accepte aussi des connexions non chiffrées sur le port standard 143, ce qui est pratique pour une découverte. Nous avons envoyé deux mails avec le script [smtp-02.php] (configuration [config-smtp-02.json]) et ils ont été déposés dans la boîte INBOX de l'utilisateur [guest@localhost]. Voici un dialogue avec le serveur IMAP :
| > php tcp-client.php --quit bye localhost 143
Client [127.0.0.1:39098] connecté au serveur [localhost:143]
Tapez vos commandes (bye pour arrêter) :
<-- [* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN AUTH=LOGIN] Dovecot ready.]
A1 LOGIN guest@localhost guest
<-- [A1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY PREVIEW STATUS=SIZE SAVEDATE LITERAL+ NOTIFY SPECIAL-USE] Logged in]
A2 SELECT INBOX
<-- [* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)]
<-- [* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft \*)] Flags permitted.]
<-- [* 2 EXISTS]
<-- [* 2 RECENT]
<-- [* OK [UNSEEN 1] First unseen.]
<-- [* OK [UIDVALIDITY 1790865054] UIDs valid]
<-- [* OK [UIDNEXT 10] Predicted next UID]
<-- [A2 OK [READ-WRITE] Select completed (0.001 + 0.000 secs).]
A3 SEARCH UNSEEN
<-- [* SEARCH 1 2]
<-- [A3 OK Search completed (0.001 + 0.000 secs).]
A4 FETCH 2 (FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])
<-- [* 2 FETCH (FLAGS (\Recent) RFC822.SIZE 28107 BODY[HEADER.FIELDS (FROM TO SUBJECT DATE)] {114}]
<-- [From: guest@localhost]
<-- [To: guest@localhost]
<-- [Subject: test-secure-server]
<-- [Date: Thu, 01 Oct 2026 17:14:39 +0200]
<-- []
<-- [)]
<-- [A4 OK Fetch completed (0.001 + 0.000 secs).]
A5 FETCH 2 BODY.PEEK[1.1]
<-- [* 2 FETCH (BODY[1.1] {25}]
<-- [ligne 1]
<-- [ligne 2]
<-- [ligne 3)]
<-- [A5 OK Fetch completed (0.002 + 0.000 + 0.001 secs).]
A6 FETCH 1 BODYSTRUCTURE
<-- [* 1 FETCH (BODYSTRUCTURE ((("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 25 2 NIL NIL NIL NIL)("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 38 0 NIL NIL NIL NIL) "alternative" ("boundary" "7tEIKgyZ") NIL NIL NIL)("application" "vnd.openxmlformats-officedocument.wordprocessingml.document" ("name" "Hello from Symfony Mailer.docx") NIL NIL "base64" 13518 NIL ("attachment" ("name" "Hello from Symfony Mailer.docx" "filename" "Hello from Symfony Mailer.docx")) NIL NIL)("application" "pdf" ("name" "Hello from Symfony Mailer.pdf") NIL NIL "base64" 26926 NIL ("attachment" ("name" "Hello from Symfony Mailer.pdf" "filename" "Hello from Symfony Mailer.pdf")) NIL NIL)("application" "vnd.oasis.opendocument.text" ("name" "Hello from Symfony Mailer.odt") NIL NIL "base64" 9514 NIL ("attachment" ("name" "Hello from Symfony Mailer.odt" "filename" "Hello from Symfony Mailer.odt")) NIL NIL)("image" "png" ("name" "=?utf-8?Q?Cours-Tutoriels-Serge-Tah=C3=A9-1568x268=2Epng?=") NIL NIL "base64" 57834 NIL ("attachment" ("filename*" "utf-8''Cours-Tutoriels-Serge-Tah%C3%A9-1568x268.png" "name*" "utf-8''Cours-Tutoriels-Serge-Tah%C3%A9-1568x268.png")) NIL NIL)("message" "rfc822" ("name" "test-localhost.eml") NIL NIL "base64" 149896 (NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL) ("text" "plain" ("charset" "us-ascii") NIL NIL "7bit" 0 0 NIL NIL NIL NIL) 1922 NIL ("attachment" ("name" "test-localhost.eml" "filename" "test-localhost.eml")) NIL NIL) "mixed" ("boundary" "9UJIzQkk") NIL NIL NIL))]
<-- [A6 OK Fetch completed (0.001 + 0.000 secs).]
A7 LOGOUT
<-- [* BYE Logging out]
<-- [A7 OK Logout completed (0.001 + 0.000 secs).]
Perte de la connexion avec le serveur...
Fin du client
|
- [A1 LOGIN guest@localhost guest] : la commande [LOGIN utilisateur motdepasse] authentifie l'utilisateur. Le serveur répond par la liste de ses capacités (CAPABILITY) ;
- [A2 SELECT INBOX] : la commande [SELECT] choisit la boîte à lettres sur laquelle porteront les commandes suivantes. Le serveur indique les drapeaux possibles (FLAGS), le nombre de messages (2 EXISTS), le n° du premier message non lu (UNSEEN 1)... ;
- [A3 SEARCH UNSEEN] : la commande [SEARCH] cherche des messages selon un critère. Il en existe une bonne vingtaine. Ici, nous cherchons les messages qui n'ont pas encore été lus (UNSEEN). Le serveur rend leurs n°s : 1 et 2 ;
- [A4 FETCH 2 (...)] : la commande [FETCH] demande des informations sur un message. Ici, ses drapeaux (FLAGS), sa taille (RFC822.SIZE) et quatre de ses entêtes. La forme [BODY.PEEK[...]] demande une partie du message sans le marquer comme lu. Le texte des entêtes est envoyé sous la forme d'un littéral : [{114}] en fin de ligne annonce que les 114 octets suivants font partie de la réponse, quel que soit leur contenu (des fins de ligne notamment). La réponse se termine par une parenthèse fermante après le littéral ;
- [A5 FETCH 2 BODY.PEEK[1.1]] : on demande la partie n° 1.1 du message n° 2, ici son texte en [text/plain]. Nous verrons bientôt comment sont numérotées les parties d'un message ;
- [A6 FETCH 1 BODYSTRUCTURE] : on demande la structure du message n° 1, celui qui a cinq pièces jointes. La réponse est une liste de listes entre parenthèses. On y reconnaît les parties du message que nous avons vues dans [Mailpit] : une liste [(("text" "plain" ...)("text" "html" ...) "alternative" ...)] pour la partie [multipart/alternative], puis une liste par pièce jointe, avec son type, ses paramètres (name), son codage (base64), sa taille, sa disposition ("attachment" avec le nom du fichier). La structure se termine par le sous-type [mixed] du message entier ;
- [A7 LOGOUT] : la fin du dialogue .
16.6.5. Des classes pour les protocoles POP3 et IMAP
Nos scripts de lecture de mails vont utiliser indifféremment un serveur POP3 ou un serveur IMAP. Nous allons donc écrire :
- une interface [ClientMessagerie] qui décrit ce que sait faire un client de lecture du courrier ;
- une classe abstraite [ClientTcp] qui regroupe ce qui est commun aux deux protocoles : une connexion TCP, éventuellement chiffrée, sur laquelle on échange des lignes de texte ;
- deux classes [Pop3Client] et [ImapClient] qui héritent de [ClientTcp] et implémentent l'interface [ClientMessagerie] ;
- une classe [PartieMime] qui décrit la structure d'un message, obtenue par la commande IMAP [BODYSTRUCTURE] .
Elles sont dans le dossier [inet/imap] :
| imap
├── ClientMessagerie.php
├── ClientTcp.php
├── ImapClient.php
├── PartieMime.php
├── Pop3Client.php
├── config-imap-01.json
├── config-pop3-01.json
├── imap-01.php
├── imap-02.php
├── imap-03.php
└── pop3-01.php
|
16.6.5.1. L'interface [ClientMessagerie]
| <?php
declare(strict_types=1);
// ce que sait faire un client de lecture du courrier, qu'il parle POP3 ou IMAP
// les messages sont désignés par leur n° (1, 2...) dans la boîte à lettres
interface ClientMessagerie
{
// connexion et authentification - rend le nombre de messages de la boîte à lettres
public function connecter(): int;
// les n°s des messages non lus
public function nonLus(): array;
// les entêtes principaux du message n° $n (Date, From, To, Subject...)
public function entêtes(int $n): array;
// le texte complet du message n° $n (entêtes et corps)
public function message(int $n): string;
// marque le message n° $n comme « à supprimer »
public function supprimer(int $n): void;
// supprime les messages marqués et ferme la connexion
public function fermer(): void;
}
|
- ligne 10 : la connexion au serveur et l'authentification de l'utilisateur. La méthode rend le nombre de messages de la boîte à lettres ;
- ligne 13 : les n°s des messages non lus ;
- ligne 16 : les principaux entêtes d'un message, sous la forme d'un dictionnaire ;
- ligne 19 : le texte complet d'un message, c'est-à-dire ce que nous avons vu dans l'onglet [Raw] de [Mailpit] ;
- ligne 22 : marque un message comme « à supprimer » ;
- ligne 25 : supprime les messages marqués et ferme la connexion .
16.6.5.2. La classe [ClientTcp]
| <?php
declare(strict_types=1);
// classe de base des clients POP3 et IMAP : une connexion TCP, éventuellement
// chiffrée (TLS), sur laquelle on échange des lignes de texte terminées par \r\n
abstract class ClientTcp
{
// la connexion avec le serveur (une ressource de type stream)
protected mixed $connexion = null;
// $infos : la configuration du serveur (server, port, tls, verify-peer, user, password...)
// $suivi : si true, les échanges client / serveur sont affichés sur la console
public function __construct(protected readonly array $infos, protected readonly bool $suivi = false)
{
}
// ouverture de la connexion avec le serveur
protected function ouvrirConnexion(): void
{
// connexion chiffrée dès son ouverture (tls://) ou non (tcp://) ?
$transport = ($this->infos["tls"] ?? false) ? "tls" : "tcp";
// faut-il vérifier le certificat du serveur ? (oui par défaut)
$vérifier = $this->infos["verify-peer"] ?? true;
$contexte = stream_context_create(["ssl" => ["verify_peer" => $vérifier, "verify_peer_name" => $vérifier]]);
$connexion = @stream_socket_client("$transport://{$this->infos["server"]}:{$this->infos["port"]}",
$numErreur, $erreur, 10, STREAM_CLIENT_CONNECT, $contexte);
if ($connexion === false) {
throw new RuntimeException("échec de la connexion au serveur "
. "[{$this->infos["server"]}:{$this->infos["port"]}] : $erreur");
}
$this->connexion = $connexion;
}
// envoi d'une ligne au serveur - $affichage : ce qu'il faut afficher en mode suivi
protected function envoyer(string $ligne, ?string $affichage = null): void
{
fwrite($this->connexion, "$ligne\r\n");
$this->afficher("--> " . ($affichage ?? $ligne));
}
// lecture d'une ligne envoyée par le serveur, rendue sans les caractères \r\n
// $afficher : faut-il afficher la ligne en mode suivi ?
protected function lireLigne(bool $afficher = true): string
{
$ligne = fgets($this->connexion);
if ($ligne === false) {
throw new RuntimeException("le serveur a fermé la connexion");
}
$ligne = rtrim($ligne, "\r\n");
if ($afficher) {
$this->afficher("<-- $ligne");
}
return $ligne;
}
// lecture de $nbOctets octets envoyés par le serveur
protected function lireOctets(int $nbOctets): string
{
$octets = "";
while (strlen($octets) < $nbOctets) {
$morceau = fread($this->connexion, $nbOctets - strlen($octets));
if ($morceau === false || $morceau === "") {
throw new RuntimeException("le serveur a fermé la connexion");
}
$octets .= $morceau;
}
$this->afficher("<-- ... $nbOctets octets ...");
return $octets;
}
// fermeture de la connexion
protected function fermerConnexion(): void
{
if ($this->connexion !== null) {
fclose($this->connexion);
$this->connexion = null;
}
}
// affichage en mode suivi
protected function afficher(string $message): void
{
if ($this->suivi) {
print "$message\n";
}
}
// analyse des entêtes d'un message : rend le dictionnaire [nom => valeur] des entêtes
// décodés (les entêtes non ASCII sont codés, par exemple =?utf-8?Q?...?=)
protected static function analyserEntêtes(string $entêtes): array
{
$dictionnaire = iconv_mime_decode_headers($entêtes, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8");
return $dictionnaire === false ? [] : $dictionnaire;
}
}
|
- ligne 7 : la classe est abstraite : on ne pourra pas l'instancier. Elle sert de classe parent aux classes [Pop3Client] et [ImapClient] ;
- ligne 10 : la connexion avec le serveur. Les fonctions de PHP la représentent par une ressource, type que PHP ne permet pas de déclarer : on utilise le type [mixed] ;
- ligne 14 : le constructeur utilise la promotion des paramètres (PHP 8.0) : les paramètres [$infos] et [$suivi] deviennent des propriétés de la classe, ici en lecture seule ([readonly], PHP 8.1). Le corps du constructeur est vide. [$infos] est la configuration du serveur, lue dans un fichier JSON. [$suivi] demande l'affichage des échanges client / serveur ;
- lignes 19-32 : l'ouverture de la connexion. Si la configuration contient [tls: true], la connexion est chiffrée dès son ouverture (transport [tls://]). Le contexte précise si le certificat du serveur doit être vérifié (rubrique [verify-peer], vraie par défaut) ;
- ligne 36 : l'envoi d'une ligne au serveur. Le paramètre [$affichage] permet d'afficher autre chose que ce qui est envoyé : nous l'utiliserons pour ne pas afficher les mots de passe ;
- ligne 44 : la lecture d'une ligne. Si le serveur a fermé la connexion, une exception est lancée ;
- ligne 58 : la lecture d'un nombre d'octets donné. Elle nous servira pour les littéraux d'IMAP. La fonction [fread] peut rendre moins d'octets que demandé : on boucle jusqu'à avoir tout reçu ;
- ligne 91 : la fonction [iconv_mime_decode_headers] analyse un bloc d'entêtes de mail et rend le dictionnaire [nom => valeur]. Elle recolle les entêtes écrits sur plusieurs lignes et décode les entêtes codés (=?utf-8?Q?...?=) .
16.6.5.3. La classe [Pop3Client]
| <?php
declare(strict_types=1);
require_once __DIR__ . "/ClientTcp.php";
require_once __DIR__ . "/ClientMessagerie.php";
// client POP3 : les commandes USER, PASS, STAT, TOP, LIST, RETR, DELE, QUIT
final class Pop3Client extends ClientTcp implements ClientMessagerie
{
// nombre de messages de la boîte à lettres
private int $nbMessages = 0;
#[\Override]
public function connecter(): int
{
$this->ouvrirConnexion();
// message de bienvenue du serveur
$this->commande("");
// authentification
$this->commande("USER {$this->infos["user"]}");
$this->commande("PASS {$this->infos["password"]}", "PASS *****");
// STAT : la réponse a la forme [+OK nbMessages tailleTotale]
$réponse = $this->commande("STAT");
$this->nbMessages = (int) explode(" ", $réponse[0])[1];
return $this->nbMessages;
}
#[\Override]
public function nonLus(): array
{
// POP3 ne mémorise pas les messages lus : tous les messages présents sont nouveaux
return $this->nbMessages === 0 ? [] : range(1, $this->nbMessages);
}
#[\Override]
public function entêtes(int $n): array
{
// TOP n 0 : les entêtes du message n° n suivis de 0 ligne du corps
$lignes = $this->commande("TOP $n 0", multiligne: true);
$entêtes = self::analyserEntêtes(implode("\r\n", array_slice($lignes, 1)));
// LIST n : la réponse a la forme [+OK n taille]
$réponse = $this->commande("LIST $n");
// on ne garde que les principaux entêtes
$résumé = array_intersect_key($entêtes, array_flip(["Date", "From", "To", "Subject", "Message-ID"]));
return [...$résumé, "Size" => explode(" ", $réponse[0])[2]];
}
#[\Override]
public function message(int $n): string
{
// RETR n : la 1re ligne de la réponse est [+OK...], les suivantes le message
$lignes = $this->commande("RETR $n", multiligne: true);
return implode("\r\n", array_slice($lignes, 1)) . "\r\n";
}
#[\Override]
public function supprimer(int $n): void
{
$this->commande("DELE $n");
}
#[\Override]
public function fermer(): void
{
// rien à faire si la connexion n'a pas pu être ouverte
if ($this->connexion === null) {
return;
}
try {
// c'est la commande QUIT qui fait supprimer les messages marqués par DELE
$this->commande("QUIT");
} finally {
$this->fermerConnexion();
}
}
// envoie une commande au serveur (si elle n'est pas vide) et rend les lignes de la réponse
// $multiligne : la réponse se termine-t-elle par une ligne réduite à un point ?
private function commande(string $commande, ?string $affichage = null, bool $multiligne = false): array
{
if ($commande !== "") {
$this->envoyer($commande, $affichage);
}
// 1re ligne : +OK ou -ERR
$ligne = $this->lireLigne();
if (str_starts_with($ligne, "-ERR")) {
throw new RuntimeException("[" . ($affichage ?? $commande) . "] : $ligne");
}
$réponse = [$ligne];
if ($multiligne) {
// les lignes suivantes, jusqu'à la ligne [.], ne sont pas affichées en mode suivi
while (($ligne = $this->lireLigne(false)) !== ".") {
// le serveur double le point qui commence une ligne : on l'enlève
$réponse[] = str_starts_with($ligne, "..") ? substr($ligne, 1) : $ligne;
}
$this->afficher("<-- ... " . (count($réponse) - 1) . " lignes ...");
$this->afficher("<-- .");
}
return $réponse;
}
}
|
- ligne 9 : la classe hérite de [ClientTcp] et implémente [ClientMessagerie]. Elle est déclarée [final] : on ne pourra pas en dériver de classe ;
- ligne 14 : l'attribut [#[\Override]] (PHP 8.3) indique que la méthode implémente une méthode d'une interface ou redéfinit celle d'une classe parent. Si ce n'est pas le cas (faute de frappe dans le nom par exemple), PHP signale une erreur ;
- lignes 15-26 : la connexion. On retrouve les commandes de [pop3-01.php]. La commande [STAT] rend le nombre de messages et leur taille totale sur une seule ligne. Le mot de passe est affiché sous la forme [*****] ;
- lignes 30-33 : le protocole POP3 ne mémorise pas les messages lus : tous les messages présents sont considérés comme nouveaux. La fonction [range(1, n)] rend le tableau [1, 2, ..., n] ;
- lignes 37-46 : la commande [TOP n 0] rend les entêtes du message n° n suivis de 0 ligne de son corps. La commande [LIST n] rend sa taille. La fonction [array_intersect_key] ne garde que les entêtes dont le nom est une clé du tableau rendu par [array_flip]. L'opérateur [...] recopie ces entêtes dans le tableau rendu, auquel on ajoute la taille ;
- lignes 50-54 : la commande [RETR n] rend le message n° n ;
- lignes 64-74 : la commande [QUIT] fait supprimer les messages marqués par la commande [DELE]. La connexion est fermée dans tous les cas (bloc [finally]) ;
- ligne 80 : la méthode [commande] est la fonction [sendCommand] de [pop3-01.php]. En mode suivi, les lignes d'une réponse multiligne ne sont pas affichées : seul leur nombre l'est .
16.6.5.4. La classe [ImapClient]
| <?php
declare(strict_types=1);
require_once __DIR__ . "/ClientTcp.php";
require_once __DIR__ . "/ClientMessagerie.php";
require_once __DIR__ . "/PartieMime.php";
// client IMAP : les commandes LOGIN, SELECT, SEARCH, FETCH, STORE, EXPUNGE, LOGOUT
// chaque commande est précédée d'une étiquette (A0001, A0002...) ; le serveur envoie
// des réponses [* ...] puis termine par une réponse [étiquette OK|NO|BAD ...]
final class ImapClient extends ClientTcp implements ClientMessagerie
{
// n° de la dernière commande envoyée (sert à fabriquer les étiquettes)
private int $numéro = 0;
// y a-t-il des messages marqués « à supprimer » ?
private bool $suppressions = false;
#[\Override]
public function connecter(): int
{
$this->ouvrirConnexion();
// message de bienvenue du serveur : [* OK ...]
[$bienvenue] = $this->lireRéponse();
if (!str_starts_with($bienvenue, "* OK")) {
throw new RuntimeException("le serveur refuse la connexion : $bienvenue");
}
// authentification
$this->commande("LOGIN " . self::chaîne($this->infos["user"]) . " " . self::chaîne($this->infos["password"]),
"LOGIN " . self::chaîne($this->infos["user"]) . " *****");
// sélection de la boîte à lettres : une des réponses a la forme [* n EXISTS]
$réponses = $this->commande("SELECT " . self::chaîne($this->infos["mailbox"] ?? "INBOX"));
$exists = array_find($réponses, fn(array $réponse): bool => ($réponse[1] ?? "") === "EXISTS");
return (int) ($exists[0] ?? 0);
}
#[\Override]
public function nonLus(): array
{
// la réponse a la forme [* SEARCH n1 n2 ...]
$réponses = $this->commande("SEARCH UNSEEN");
$search = array_find($réponses, fn(array $réponse): bool => $réponse[0] === "SEARCH") ?? ["SEARCH"];
return array_map(intval(...), array_slice($search, 1));
}
#[\Override]
public function entêtes(int $n): array
{
// BODY.PEEK[HEADER] : les entêtes du message, sans le marquer comme lu
$données = $this->fetch($n, "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])");
$entêtes = self::analyserEntêtes($données["BODY[HEADER]"]);
// on ne garde que les principaux entêtes
$résumé = array_intersect_key($entêtes, array_flip(["Date", "From", "To", "Subject", "Message-ID"]));
return [...$résumé, "Flags" => implode(" ", $données["FLAGS"]), "Size" => $données["RFC822.SIZE"]];
}
#[\Override]
public function message(int $n): string
{
// BODY[] : le message entier ; le serveur le marque comme lu (drapeau \Seen)
return $this->fetch($n, "BODY[]")["BODY[]"];
}
// la structure MIME du message n° $n
public function structure(int $n): PartieMime
{
return PartieMime::depuisBodyStructure($this->fetch($n, "BODYSTRUCTURE")["BODYSTRUCTURE"]);
}
// le contenu (encodé) de la partie n° $section (1, 1.2...) du message n° $n
public function partie(int $n, string $section): string
{
return $this->fetch($n, "BODY[$section]")["BODY[$section]"] ?? "";
}
#[\Override]
public function supprimer(int $n): void
{
// on ajoute le drapeau \Deleted au message
$this->commande("STORE $n +FLAGS (\\Deleted)");
$this->suppressions = true;
}
#[\Override]
public function fermer(): void
{
// rien à faire si la connexion n'a pas pu être ouverte
if ($this->connexion === null) {
return;
}
try {
// EXPUNGE supprime les messages qui ont le drapeau \Deleted
if ($this->suppressions) {
$this->commande("EXPUNGE");
}
$this->commande("LOGOUT");
} finally {
$this->fermerConnexion();
}
}
// commande FETCH n éléments : rend le dictionnaire [élément => valeur] de la réponse
// [* n FETCH (élément1 valeur1 élément2 valeur2 ...)]
private function fetch(int $n, string $éléments): array
{
$réponses = $this->commande("FETCH $n $éléments");
$fetch = array_find($réponses, fn(array $réponse): bool => ($réponse[1] ?? "") === "FETCH");
if ($fetch === null) {
throw new RuntimeException("pas de message n° $n");
}
$dictionnaire = [];
foreach (array_chunk($fetch[2], 2) as [$clé, $valeur]) {
$dictionnaire[strtoupper($clé)] = $valeur;
}
return $dictionnaire;
}
// envoie une commande étiquetée et rend ses réponses [* ...], chacune découpée en jetons
// $affichage : ce qu'il faut afficher en mode suivi (pour cacher un mot de passe)
private function commande(string $commande, ?string $affichage = null): array
{
$étiquette = sprintf("A%04d", ++$this->numéro);
$this->envoyer("$étiquette $commande", $affichage === null ? null : "$étiquette $affichage");
$réponses = [];
while (true) {
[$texte, $littéraux] = $this->lireRéponse();
// réponse finale [étiquette OK|NO|BAD texte] ?
if (str_starts_with($texte, "$étiquette ")) {
$statut = substr($texte, strlen($étiquette) + 1);
if (!str_starts_with($statut, "OK")) {
throw new RuntimeException("[" . ($affichage ?? $commande) . "] : $statut");
}
return $réponses;
}
// réponse [* ...]
if (str_starts_with($texte, "* ")) {
$position = 2;
$réponses[] = self::jetons($texte, $position, $littéraux);
}
}
}
// lit une réponse du serveur : une ligne qui peut contenir des littéraux. Un littéral
// est annoncé par {n} en fin de ligne : suivent n octets, puis la suite de la ligne.
// rend le texte de la réponse, où le littéral n° k est remplacé par \0k\0, et les littéraux
private function lireRéponse(): array
{
$texte = "";
$littéraux = [];
while (true) {
$ligne = $this->lireLigne();
if (preg_match('/\{(\d+)\}$/', $ligne, $champs) !== 1) {
return [$texte . $ligne, $littéraux];
}
$texte .= substr($ligne, 0, -strlen($champs[0])) . "\0" . count($littéraux) . "\0";
$littéraux[] = $this->lireOctets((int) $champs[1]);
}
}
// découpe $texte en jetons à partir de la position $position, jusqu'à la fin du texte ou
// jusqu'à une parenthèse fermante. Une liste (...) devient un tableau, NIL devient null,
// une chaîne "..." ou un littéral devient une chaîne, tout le reste (atome) aussi
private static function jetons(string $texte, int &$position, array $littéraux): array
{
$jetons = [];
$longueur = strlen($texte);
while ($position < $longueur) {
$c = $texte[$position];
if ($c === " ") {
$position++;
} elseif ($c === "(") {
// une liste : appel récursif
$position++;
$jetons[] = self::jetons($texte, $position, $littéraux);
} elseif ($c === ")") {
// fin de la liste en cours
$position++;
return $jetons;
} elseif ($c === '"') {
// une chaîne entre guillemets où \" et \\ représentent " et \
$chaîne = "";
for ($position++; $texte[$position] !== '"'; $position++) {
if ($texte[$position] === "\\") {
$position++;
}
$chaîne .= $texte[$position];
}
$position++;
$jetons[] = $chaîne;
} elseif ($c === "\0") {
// un littéral
$fin = strpos($texte, "\0", $position + 1);
$jetons[] = $littéraux[(int) substr($texte, $position + 1, $fin - $position - 1)];
$position = $fin + 1;
} else {
// un atome : jusqu'au prochain espace ou parenthèse, sauf entre crochets
// (BODY[HEADER.FIELDS (FROM TO)] est un seul atome)
$début = $position;
$crochets = 0;
while ($position < $longueur
&& ($crochets > 0 || !in_array($texte[$position], [" ", "(", ")"], true))) {
$crochets += match ($texte[$position]) { "[" => 1, "]" => -1, default => 0 };
$position++;
}
$atome = substr($texte, $début, $position - $début);
$jetons[] = $atome === "NIL" ? null : $atome;
}
}
return $jetons;
}
// une chaîne IMAP : entre guillemets, avec \ devant les caractères " et \
private static function chaîne(string $texte): string
{
return '"' . addcslashes($texte, '"\\') . '"';
}
}
|
- ligne 15 : le n° de la dernière commande. Il sert à fabriquer les étiquettes [A0001], [A0002]... ;
- lignes 20-34 : la connexion. Le serveur envoie d'abord un message de bienvenue [* OK ...]. On envoie ensuite les commandes [LOGIN] et [SELECT]. La méthode [commande] rend les réponses [* ...] du serveur, chacune découpée en jetons : la réponse [* 2 EXISTS] devient le tableau [["2", "EXISTS"]]. La fonction [array_find] (PHP 8.4) rend le premier élément du tableau qui vérifie la condition, ou [null] ;
- lignes 38-43 : la commande [SEARCH UNSEEN]. La réponse [* SEARCH 1 2] devient [["SEARCH", "1", "2"]]. On en garde les éléments à partir du n° 1 ([array_slice]), transformés en entiers par la fonction [intval]. La notation [intval(...)] (PHP 8.1) fabrique une fonction à partir de la fonction [intval] : c'est la syntaxe des callables de première classe ;
- lignes 47-54 : la commande [FETCH n (FLAGS RFC822.SIZE BODY.PEEK[HEADER])] rend les drapeaux, la taille et les entêtes du message n° n ;
- lignes 58-61 : la commande [FETCH n BODY[]] rend le message n° n en entier. Le serveur lui ajoute le drapeau [\Seen] : le message est désormais lu ;
- lignes 65-67 : la commande [FETCH n BODYSTRUCTURE] rend la structure du message, que la classe [PartieMime] transforme en un arbre d'objets ;
- lignes 71-73 : la commande [FETCH n BODY[section]] rend le contenu de la partie n° [section] du message, tel qu'il est codé dans le message (base64, quoted-printable...) ;
- lignes 77-81 : la commande [STORE n +FLAGS (\Deleted)] ajoute le drapeau [\Deleted] au message. Il n'est pas encore supprimé ;
- lignes 85-98 : la commande [EXPUNGE] supprime les messages qui ont le drapeau [\Deleted]. La commande [LOGOUT] termine le dialogue ;
- lignes 104-115 : la méthode [fetch] envoie une commande [FETCH] et transforme la liste de la réponse [[* n FETCH (élément1 valeur1 élément2 valeur2...)]] en dictionnaire [[élément1 => valeur1, élément2 => valeur2...]]. La fonction [array_chunk] découpe la liste en paires, que la boucle [foreach] déstructure en [[$clé, $valeur]] ;
- lignes 120-133 : la méthode [commande] envoie une commande précédée de son étiquette, puis lit les réponses du serveur jusqu'à celle qui commence par l'étiquette. Si cette réponse finale n'est pas [OK], une exception est lancée ;
- lignes 146-153 : la lecture d'une réponse. Si une ligne se termine par [{n}], suivent n octets (le littéral) puis la suite de la réponse. Dans le texte de la réponse, chaque littéral est remplacé par un repère [\0k\0] (\0 est le caractère de code 0, qui ne peut pas apparaître dans une ligne de texte IMAP) et son contenu est rangé dans le tableau [$littéraux] ;
- lignes 163-209 : la méthode [jetons] découpe le texte d'une réponse en jetons :
- une liste entre parenthèses devient un tableau. La méthode s'appelle elle-même (appel récursif) pour analyser le contenu de la liste, et rend la main sur la parenthèse fermante ;
- une chaîne entre guillemets ou un littéral devient une chaîne de caractères ;
- [NIL] devient [null] ;
- tout le reste (un atome) devient une chaîne. Un atome peut contenir des crochets, qui eux-mêmes peuvent contenir des espaces et des parenthèses : [BODY[HEADER.FIELDS (FROM TO)]] est un seul atome. L'expression [match] ajoute 1 au compteur de crochets ouverts pour [[], lui retranche 1 pour []] ;
- le paramètre [$position], passé par référence, est la position courante dans le texte : elle avance au fil de l'analyse, y compris dans les appels récursifs ;
- ligne 213 : une chaîne IMAP est entourée de guillemets. La fonction [addcslashes] fait précéder les caractères ["] et [\] d'un caractère [\] .
16.6.5.5. La classe [PartieMime]
| <?php
declare(strict_types=1);
// une partie d'un message MIME, telle que la décrit la réponse BODYSTRUCTURE d'un serveur IMAP
// un message est un arbre de parties : une partie [multipart/xxx] a des sous-parties
final readonly class PartieMime
{
public function __construct(
// type et sous-type MIME en minuscules : text/plain, multipart/mixed, application/pdf...
public string $type,
public string $sousType,
// paramètres du type, par exemple [charset => utf-8]
public array $paramètres = [],
// codage du contenu : 7bit, 8bit, base64, quoted-printable...
public string $codage = "7bit",
// disposition (attachment, inline) ou null, et ses paramètres, par exemple [filename => x.pdf]
public ?string $disposition = null,
public array $paramètresDisposition = [],
// les sous-parties d'une partie [multipart/xxx]
public array $parties = [],
) {
}
// fabrique l'arbre des parties à partir de la liste de jetons BODYSTRUCTURE
public static function depuisBodyStructure(array $structure): self
{
// partie multiple : ((sous-partie 1) (sous-partie 2)... "sous-type" (paramètres) disposition...)
if (is_array($structure[0])) {
// les sous-parties sont les premiers éléments qui sont des listes
$parties = [];
$i = 0;
while (is_array($structure[$i])) {
$parties[] = self::depuisBodyStructure($structure[$i++]);
}
return new self(
type: "multipart",
sousType: strtolower($structure[$i]),
paramètres: self::dictionnaire($structure[$i + 1] ?? null),
disposition: self::disposition($structure[$i + 2] ?? null)[0],
paramètresDisposition: self::disposition($structure[$i + 2] ?? null)[1],
parties: $parties,
);
}
// partie simple : ("type" "sous-type" (paramètres) id description "codage" taille ...)
$type = strtolower($structure[0]);
$sousType = strtolower($structure[1]);
// la disposition vient après des champs qui dépendent du type de la partie
$indexDisposition = match (true) {
// text/xxx : ... taille nbLignes md5 disposition
$type === "text" => 9,
// message/rfc822 : ... taille enveloppe structure nbLignes md5 disposition
$type === "message" && $sousType === "rfc822" => 11,
// autres : ... taille md5 disposition
default => 8,
};
[$disposition, $paramètresDisposition] = self::disposition($structure[$indexDisposition] ?? null);
return new self(
type: $type,
sousType: $sousType,
paramètres: self::dictionnaire($structure[2]),
codage: strtolower($structure[5] ?? "7bit"),
disposition: $disposition,
paramètresDisposition: $paramètresDisposition,
);
}
// le nom du fichier d'une pièce jointe, ou null
public function nomFichier(): ?string
{
// le nom est le paramètre [filename] de la disposition, ou à défaut le paramètre [name]
// du type. Il peut être codé de deux façons :
// - filename*=utf-8''Tah%C3%A9.png (RFC 2231) ;
// - name="=?utf-8?Q?Tah=C3=A9.png?=" (RFC 2047) ;
$codé = $this->paramètresDisposition["filename*"] ?? $this->paramètres["name*"] ?? null;
if ($codé !== null) {
// jeu de caractères''nom codé façon URL
[$charset, , $nom] = explode("'", $codé, 3);
return mb_convert_encoding(rawurldecode($nom), "UTF-8", $charset);
}
$nom = $this->paramètresDisposition["filename"] ?? $this->paramètres["name"] ?? null;
return $nom === null ? null : iconv_mime_decode($nom, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8");
}
// une liste IMAP (clé1 valeur1 clé2 valeur2...) ou NIL transformée en dictionnaire
private static function dictionnaire(?array $liste): array
{
$dictionnaire = [];
foreach (array_chunk($liste ?? [], 2) as [$clé, $valeur]) {
$dictionnaire[strtolower($clé)] = $valeur;
}
return $dictionnaire;
}
// une disposition IMAP ("attachment" (paramètres)) ou NIL : rend [disposition, paramètres]
private static function disposition(mixed $disposition): array
{
if (!is_array($disposition)) {
return [null, []];
}
return [strtolower($disposition[0]), self::dictionnaire($disposition[1] ?? null)];
}
}
|
- ligne 7 : une classe [readonly] (PHP 8.2) n'a que des propriétés en lecture seule : une fois construit, un objet [PartieMime] ne peut plus être modifié ;
- lignes 9-22 : toutes les propriétés sont déclarées par promotion des paramètres du constructeur. Elles ont des valeurs par défaut, ce qui permet de ne passer que certains paramètres, par leur nom (arguments nommés) ;
- ligne 26 : la méthode statique [depuisBodyStructure] fabrique l'arbre des parties à partir de la liste de jetons de la réponse [BODYSTRUCTURE] ;
- lignes 29-42 : une partie multiple commence par les listes de ses sous-parties, chacune transformée en objet [PartieMime] par un appel récursif. Viennent ensuite son sous-type (mixed, alternative...), ses paramètres (boundary) et sa disposition ;
- lignes 45-57 : une partie simple commence par son type, son sous-type, ses paramètres, un identifiant, une description, son codage et sa taille. La position de la disposition dépend du type de la partie : une partie [text/xxx] a en plus un nombre de lignes, une partie [message/rfc822] a en plus l'enveloppe et la structure du message qu'elle contient, et un nombre de lignes. L'expression [match (true)] choisit la première branche dont la condition est vraie ;
- lignes 69-82 : le nom du fichier d'une pièce jointe. Nous avons vu dans [Mailpit] qu'il peut être codé de deux façons :
- le codage RFC 2231 [utf-8''Tah%C3%A9.png] : le jeu de caractères, deux apostrophes, puis le nom codé comme dans une URL. On le décode avec [rawurldecode] puis on le convertit en UTF-8 avec [mb_convert_encoding] ;
- le codage RFC 2047 [=?utf-8?Q?Tah=C3=A9.png?=] : on le décode avec la fonction [iconv_mime_decode] ;
- ligne 86 : une liste IMAP [("charset" "utf-8")] devient le dictionnaire [["charset" => "utf-8"]] ;
- ligne 96 : une disposition [("attachment" ("filename" "x.pdf"))] devient le tableau [["attachment", ["filename" => "x.pdf"]]] .
16.6.6. Client POP3 / IMAP [imap-01.php]
Le script [imap-01.php] joue le rôle du script du même nom du cours PHP 7, qui utilisait les fonctions [imap_open], [imap_num_msg], [imap_search], [imap_headerinfo], [imap_body], [imap_delete], [imap_expunge] et [imap_close]. Il exploite le fichier JSON [config-imap-01.json] suivant :
| {
"imap.gmail.com:993": {
"enabled": false,
"protocol": "imap",
"server": "imap.gmail.com",
"port": 993,
"tls": true,
"verify-peer": true,
"user": "php8parlexemple@gmail.com",
"password": "mot de passe d'application",
"mailbox": "INBOX",
"output-dir": "output/gmail-imap"
},
"localhost:993": {
"protocol": "imap",
"server": "localhost",
"port": 993,
"tls": true,
"verify-peer": false,
"user": "guest@localhost",
"password": "guest",
"mailbox": "INBOX",
"output-dir": "output/localhost-imap"
},
"localhost:1110": {
"protocol": "pop3",
"server": "localhost",
"port": 1110,
"tls": false,
"user": "guest",
"password": "guest",
"output-dir": "output/localhost-pop3"
}
}
|
Le fichier [config-imap-01.json] définit un dictionnaire de serveurs IMAP / POP3 à contacter. Chaque élément est de la forme [clé => valeur], où :
- [clé] : est un nom désignant le serveur. Nous en avons trois ici :
- [imap.gmail.com:993] : le serveur IMAP de Gmail, qui écoute sur le port 993 avec une connexion chiffrée. Il est désactivé ([enabled] à [false]) : mettez-y votre adresse Gmail et votre mot de passe d'application, puis passez [enabled] à [true] ;
- [localhost:993] : le serveur IMAP local, avec une connexion chiffrée par un certificat autosigné, qu'on ne vérifie pas ;
- [localhost:1110] : le serveur POP3 de [Mailpit] ;
- [valeur] : est un dictionnaire précisant les points suivants :
- [protocol] : le protocole, [imap] ou [pop3] ;
- [server] : le nom du serveur IMAP ou POP3 ;
- [port] : le port du serveur IMAP ou POP3 ;
- [tls] : la connexion est-elle chiffrée ?
- [verify-peer] : faut-il vérifier le certificat du serveur ? Sous Windows, si la vérification échoue avec un serveur public, vérifiez que la directive [openssl.cafile] du fichier [php.ini] désigne un fichier de certificats d'autorités, comme le fait [Laragon] ;
- [user] : le propriétaire de la boîte à lettres ;
- [password] : son mot de passe ;
- [mailbox] : la boîte à lettres IMAP à lire, INBOX étant celle où arrivent les nouveaux messages ;
- [output-dir] : le dossier dans lequel on doit enregistrer les messages .
Avec le protocole POP3, après avoir lu un message, on le supprimera. C'est le fonctionnement habituel des serveurs POP3 : un message lu n'est pas conservé sur le serveur.
Le script [imap-01.php] est le suivant :
| <?php
declare(strict_types=1);
// client IMAP (Internet Message Access Protocol) / POP3 permettant de lire des mails
// les boîtes à lettres à lire sont décrites dans un fichier de configuration
require_once __DIR__ . "/ImapClient.php";
require_once __DIR__ . "/Pop3Client.php";
// les paramètres de lecture du courrier
const CONFIG_FILE_NAME = "config-imap-01.json";
// affichage des échanges client / serveur ?
const SUIVI = false;
// on récupère la configuration
$mailboxes = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// lecture des boîtes à lettres
foreach ($mailboxes as $name => $infos) {
// boîte à lettres désactivée dans la configuration ?
if (($infos["enabled"] ?? true) === false) {
print "------------Boîte à lettres [$name] désactivée\n";
continue;
}
// suivi
print "------------Lecture de la boîte à lettres [$name]\n";
// lecture de la boîte à lettres
readmailbox($name, $infos);
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function créerClient(array $infos): ClientMessagerie
{
// un client IMAP ou POP3 selon la configuration
return match ($infos["protocol"]) {
"imap" => new ImapClient($infos, SUIVI),
"pop3" => new Pop3Client($infos, SUIVI),
};
}
// --------------------------------------------------------------------------
function readmailbox(string $name, array $infos): void
{
// le client de la boîte à lettres
$client = créerClient($infos);
try {
// connexion
$nbmsg = $client->connecter();
print "Connexion établie avec le serveur [$name].\n";
// total des messages dans la boîte à lettres
print "Il y a [$nbmsg] messages dans la boîte à lettres [$name]\n";
if ($nbmsg === 0) {
return;
}
// messages non lus dans la boîte à lettres
print "Récupération de la liste des messages non lus de la boîte à lettres [$name]\n";
$msgNumbers = $client->nonLus();
if (count($msgNumbers) === 0) {
print "Il n'y a pas de nouveaux messages dans la boîte à lettres [$name]\n";
return;
}
foreach ($msgNumbers as $msgNumber) {
// on récupère les entêtes du message n° $msgNumber
print "Entêtes du message n° [$msgNumber] de la boîte à lettres [$name]\n";
print_r($client->entêtes($msgNumber));
// on récupère le texte complet du message n° $msgNumber et on l'enregistre
saveMessage($client->message($msgNumber), $msgNumber, $infos);
// si le protocole est POP3, on supprime le message
if ($infos["protocol"] === "pop3") {
$client->supprimer($msgNumber);
}
}
} catch (RuntimeException $ex) {
print "Erreur : " . $ex->getMessage() . "\n";
} finally {
// dans tous les cas, on ferme la connexion
try {
$client->fermer();
print "Fermeture de la connexion réussie.\n";
} catch (RuntimeException $ex) {
print "La fermeture de la connexion a échoué : " . $ex->getMessage() . "\n";
}
}
}
// --------------------------------------------------------------------------
function saveMessage(string $message, int $msgNumber, array $infos): void
{
// le message est enregistré dans le fichier [output-dir/message-N.eml]
$fileName = "{$infos["output-dir"]}/message-$msgNumber.eml";
print "Enregistrement du message dans le fichier [$fileName]\n";
// on crée le dossier si besoin est (true : avec ses dossiers parents)
if (!is_dir($infos["output-dir"])) {
mkdir($infos["output-dir"], recursive: true);
}
// on enregistre le message
if (file_put_contents($fileName, $message) === false) {
print "Échec de l'enregistrement\n";
}
}
|
Commentaires :
- lignes 8-9 : on charge les classes des deux clients, qui chargent elles-mêmes la classe [ClientTcp] et l'interface [ClientMessagerie] ;
- ligne 14 : à [true], les échanges client / serveur sont affichés. Essayez ;
- lignes 20-29 : on boucle sur l'ensemble des serveurs trouvés dans le fichier de configuration, sauf ceux qui sont désactivés ;
- lignes 35-41 : la fonction [créerClient] rend un client IMAP ou POP3 selon la configuration. Son type de retour est l'interface [ClientMessagerie] : le reste du script ne sait pas à quel protocole il a affaire. C'est le polymorphisme ;
- ligne 51 : connexion et authentification ;
- ligne 60 : les n°s des messages non lus ;
- ligne 68 : on affiche les entêtes du message avec la fonction [print_r] ;
- ligne 70 : on enregistre le texte complet du message dans un fichier ;
- ligne 72 : avec le protocole POP3, on marque le message pour qu'il soit supprimé ;
- lignes 78-86 : dans tous les cas, on ferme la connexion, ce qui supprime les messages marqués. Si les instructions [return] du bloc [try] sont exécutées, le bloc [finally] l'est aussi ;
- ligne 97 : la fonction [mkdir] crée le dossier, avec ses dossiers parents grâce à l'argument nommé [recursive: true] .
Exécutons le script [smtp-02.php] pour que l'utilisateur [guest@localhost] ait de nouveaux messages. Ceci fait, exécutons le script [imap-01.php] pour lire les boîtes à lettres. Les résultats console sont les suivants :
| > php imap-01.php
------------Boîte à lettres [imap.gmail.com:993] désactivée
------------Lecture de la boîte à lettres [localhost:993]
Connexion établie avec le serveur [localhost:993].
Il y a [2] messages dans la boîte à lettres [localhost:993]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:993]
Entêtes du message n° [1] de la boîte à lettres [localhost:993]
Array
(
[From] => guest@localhost
[To] => guest@localhost
[Subject] => test-localhost
[Message-ID] => <b80fff003b5b37b74244dc69674f3ce4@localhost>
[Date] => Thu, 01 Oct 2026 17:14:39 +0200
[Flags] =>
[Size] => 259771
)
Enregistrement du message dans le fichier [output/localhost-imap/message-1.eml]
Entêtes du message n° [2] de la boîte à lettres [localhost:993]
Array
(
[From] => guest@localhost
[To] => guest@localhost
[Subject] => test-secure-server
[Message-ID] => <759bf5367e3f1136ce964ded7f702e09@localhost>
[Date] => Thu, 01 Oct 2026 17:14:39 +0200
[Flags] =>
[Size] => 28107
)
Enregistrement du message dans le fichier [output/localhost-imap/message-2.eml]
Fermeture de la connexion réussie.
------------Lecture de la boîte à lettres [localhost:1110]
Connexion établie avec le serveur [localhost:1110].
Il y a [2] messages dans la boîte à lettres [localhost:1110]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:1110]
Entêtes du message n° [1] de la boîte à lettres [localhost:1110]
Array
(
[From] => guest@localhost
[To] => guest@localhost
[Subject] => test-secure-server
[Message-ID] => <759bf5367e3f1136ce964ded7f702e09@localhost>
[Date] => Thu, 01 Oct 2026 17:14:39 +0200
[Size] => 28107
)
Enregistrement du message dans le fichier [output/localhost-pop3/message-1.eml]
Entêtes du message n° [2] de la boîte à lettres [localhost:1110]
Array
(
[From] => guest@localhost
[To] => guest@localhost
[Subject] => test-localhost
[Message-ID] => <b80fff003b5b37b74244dc69674f3ce4@localhost>
[Date] => Thu, 01 Oct 2026 17:14:39 +0200
[Size] => 259771
)
Enregistrement du message dans le fichier [output/localhost-pop3/message-2.eml]
Fermeture de la connexion réussie.
Terminé
|
- avec IMAP, les deux messages déposés dans la boîte INBOX sont lus. Les drapeaux [Flags] sont vides : les messages n'ont pas encore été lus ;
- avec POP3, on retrouve les mêmes messages dans la boîte de [Mailpit], dans l'ordre inverse. Les messages lus sont supprimés .
Si aussitôt après, nous réexécutons le script [imap-01.php], les résultats sont alors les suivants :
| > php imap-01.php
------------Boîte à lettres [imap.gmail.com:993] désactivée
------------Lecture de la boîte à lettres [localhost:993]
Connexion établie avec le serveur [localhost:993].
Il y a [2] messages dans la boîte à lettres [localhost:993]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:993]
Il n'y a pas de nouveaux messages dans la boîte à lettres [localhost:993]
Fermeture de la connexion réussie.
------------Lecture de la boîte à lettres [localhost:1110]
Connexion établie avec le serveur [localhost:1110].
Il y a [0] messages dans la boîte à lettres [localhost:1110]
Fermeture de la connexion réussie.
Terminé
|
- il y a toujours le même nombre de messages dans la boîte IMAP, mais il n'y a plus de messages non lus. Ceci montre que l'exécution précédente a passé les messages lus du statut « non lu » au statut « lu » (drapeau [\Seen]) ;
- il n'y a plus de messages dans la boîte POP3 : ils ont été supprimés lors de l'exécution précédente .
Les messages ont été enregistrés localement dans les dossiers [output/localhost-imap] et [output/localhost-pop3], sous les noms [message-1.eml] et [message-2.eml]. L'extension [.eml] est celle des fichiers de mails : un double-clic sur l'un de ces fichiers l'ouvre avec le logiciel de messagerie de Windows. Le contenu du fichier est le texte complet du message, tel que nous l'avons vu dans l'onglet [Raw] de [Mailpit] : les textes du message et le contenu codé des pièces jointes y sont mélangés.
Essayons d'améliorer ce script pour avoir, dans des fichiers séparés, les différents textes du message ainsi que les pièces jointes.
16.6.7. Client IMAP amélioré [imap-02.php]
Le serveur IMAP sait nous donner la structure d'un message (commande [BODYSTRUCTURE]) puis le contenu de chacune de ses parties (commande [FETCH n BODY[section]]). Nous allons nous en servir dans le script [imap-02.php]. Le protocole POP3, lui, ne connaît que le message entier : le script ne traitera que les boîtes à lettres IMAP.
Revenons sur la réponse à la commande [BODYSTRUCTURE] obtenue lors de la découverte du protocole IMAP. Elle décrit un message de type [multipart/mixed] qui a six parties :
- la 1re partie est de type [multipart/alternative]. Elle a elle-même deux sous-parties : [text/plain] et [text/html], codées en [quoted-printable] et ayant un paramètre [charset=utf-8] ;
- les cinq parties suivantes sont les pièces jointes, codées en [base64], ayant chacune une disposition [attachment] avec le nom du fichier .
Les parties sont numérotées de la façon suivante :
- le message entier, de type [multipart/mixed], n'a pas de n°. Ses parties portent les n°s 1, 2... ;
- lorsqu'une partie a des sous-parties, celles-ci sont numérotées x.1, x.2... où x est le n° de la partie englobante. Ainsi, le texte [text/plain] du message est la partie n° 1.1 : c'est celle que nous avons demandée avec la commande [FETCH 2 BODY.PEEK[1.1]] .
Le script [imap-02.php] est le suivant :
| <?php
declare(strict_types=1);
// client IMAP qui enregistre séparément les textes et les pièces jointes des messages non lus
// il utilise la structure MIME des messages fournie par le serveur IMAP (commande BODYSTRUCTURE)
require_once __DIR__ . "/ImapClient.php";
// les paramètres de lecture du courrier
const CONFIG_FILE_NAME = "config-imap-01.json";
// on récupère la configuration
$mailboxes = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// lecture des boîtes à lettres
foreach ($mailboxes as $name => $infos) {
// seules les boîtes à lettres IMAP actives nous intéressent
if (($infos["enabled"] ?? true) === false || $infos["protocol"] !== "imap") {
continue;
}
print "------------Lecture de la boîte à lettres [$name]\n";
readmailbox($name, $infos);
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function readmailbox(string $name, array $infos): void
{
$client = new ImapClient($infos);
try {
// connexion
$nbmsg = $client->connecter();
print "Connexion établie avec le serveur [$name].\n";
print "Il y a [$nbmsg] messages dans la boîte à lettres [$name]\n";
// messages non lus
print "Récupération de la liste des messages non lus de la boîte à lettres [$name]\n";
$msgNumbers = $client->nonLus();
if (count($msgNumbers) === 0) {
print "Il n'y a pas de nouveaux messages dans la boîte à lettres [$name]\n";
}
foreach ($msgNumbers as $msgNumber) {
// les entêtes du message serviront à préfixer ses textes
$entêtes = $client->entêtes($msgNumber);
// la structure du message
$structure = $client->structure($msgNumber);
// on parcourt ses parties
$outputDir = "{$infos["output-dir"]}/message-$msgNumber";
getParts($client, $msgNumber, $structure, "", $outputDir, $entêtes);
}
} catch (RuntimeException $ex) {
print "Erreur : " . $ex->getMessage() . "\n";
} finally {
try {
$client->fermer();
print "Fermeture de la connexion réussie.\n";
} catch (RuntimeException $ex) {
print "La fermeture de la connexion a échoué : " . $ex->getMessage() . "\n";
}
}
}
// --------------------------------------------------------------------------
function getParts(ImapClient $client, int $msgNumber, PartieMime $part, string $section,
string $outputDir, array $entêtes): void
{
// affiche le type de la partie [$part] du message n° $msgNumber, puis :
// - si elle a des sous-parties, les traite à leur tour (appel récursif) ;
// - sinon, l'enregistre dans le dossier [$outputDir]
// $section est le n° IMAP de la partie : 1, 2, 1.1, 1.2...
// la partie racine (le message entier) n'a pas de n° : on affiche 0
print "-----contenu de la partie n° [" . ($section === "" ? "0" : $section) . "]\n";
print "Content-Type: " . strtoupper("$part->type/$part->sousType") . "\n";
print "Transfer-Encoding : $part->codage\n";
// des sous-parties ?
if ($part->type === "multipart") {
foreach ($part->parties as $i => $subpart) {
// les sous-parties de la partie n° x sont numérotées x.1, x.2...
$subsection = $section === "" ? (string) ($i + 1) : "$section." . ($i + 1);
getParts($client, $msgNumber, $subpart, $subsection, $outputDir, $entêtes);
}
return;
}
// un message qui n'est pas multipart a une unique partie, de n° 1
if ($section === "") {
$section = "1";
}
// on enregistre la partie
savePart($client, $msgNumber, $part, $section, $outputDir, $entêtes);
}
// --------------------------------------------------------------------------
function savePart(ImapClient $client, int $msgNumber, PartieMime $part, string $section,
string $outputDir, array $entêtes): void
{
// si le dossier n'existe pas, on le crée
if (!is_dir($outputDir)) {
mkdir($outputDir, recursive: true);
}
// une pièce jointe ?
$nomFichier = $part->nomFichier();
if ($part->disposition === "attachment" && $nomFichier !== null) {
saveAttachment($client->partie($msgNumber, $section), $part, "$outputDir/$nomFichier");
return;
}
// un texte ?
match ("$part->type/$part->sousType") {
"text/plain" => saveText("$outputDir/message.txt", false,
$client->partie($msgNumber, $section), $part, $entêtes),
"text/html" => saveText("$outputDir/message.html", true,
$client->partie($msgNumber, $section), $part, $entêtes),
// autres cas : on ne traitera pas cette partie
default => print "Partie [$section] ignorée\n",
};
}
// --------------------------------------------------------------------------
function décoder(string $texte, string $codage): string
{
// décodage d'un contenu codé en base64 ou en quoted-printable
return match ($codage) {
"base64" => base64_decode($texte),
"quoted-printable" => quoted_printable_decode($texte),
// 7bit, 8bit, binary : pas de codage
default => $texte,
};
}
// --------------------------------------------------------------------------
function saveText(string $fileName, bool $html, string $texte, PartieMime $part, array $entêtes): void
{
// le texte est décodé puis converti en UTF-8 s'il utilise un autre jeu de caractères
$texte = décoder($texte, $part->codage);
$charset = $part->paramètres["charset"] ?? "utf-8";
if (strtolower($charset) !== "utf-8") {
$texte = mb_convert_encoding($texte, "UTF-8", $charset);
}
// on le fait précéder des entêtes From, To, Subject du message
$finDeLigne = $html ? "<br/>\n" : "\n";
$contenu = "From: {$entêtes["From"]}$finDeLigne"
. "To: {$entêtes["To"]}$finDeLigne"
. "Subject: {$entêtes["Subject"]}$finDeLigne$finDeLigne"
. $texte;
print "Sauvegarde d'un message dans [$fileName]\n";
if (file_put_contents($fileName, $contenu) === false) {
print "Impossible de créer le fichier [$fileName]\n";
}
}
// --------------------------------------------------------------------------
function saveAttachment(string $contenu, PartieMime $part, string $fileName): void
{
print "Sauvegarde de la pièce jointe dans [$fileName]\n";
if (file_put_contents($fileName, décoder($contenu, $part->codage)) === false) {
print "La pièce jointe n'a pu être sauvegardée dans [$fileName]\n";
}
}
|
Commentaires :
- ligne 19 : seules les boîtes à lettres IMAP actives sont lues ;
- ligne 45 : les entêtes du message serviront à préfixer les textes enregistrés ;
- ligne 47 : la structure du message : un objet [PartieMime] ;
- ligne 50 : la fonction [getParts] parcourt les parties du message. On commence par la partie racine, le message entier, qui n'a pas de n° de section ;
- ligne 65 : la fonction [getParts] reçoit les paramètres suivants :
- [$client] : le client IMAP ;
- [$msgNumber] : le n° du message dont on veut les parties ;
- [$part] : un objet qui représente une partie du message ;
- [$section] : le n° de cette partie ;
- [$outputDir] : le dossier où enregistrer les parties ;
- [$entêtes] : des informations générales sur le mail (expéditeur, destinataire, sujet...) ;
- lignes 73-75 : on affiche le n° de la partie, son type de contenu et son codage. Le message entier est affiché comme la partie n° 0 ;
- lignes 77-83 : si la partie a des sous-parties, on demande à voir chacune d'elles par un appel récursif à la fonction [getParts]. Leur n° de section est [$section.i] ;
- lignes 86-87 : un message qui n'est pas multipart (un simple texte) n'a qu'une partie, de n° 1 ;
- ligne 90 : lorsqu'il n'y a pas de sous-parties, on enregistre le contenu de la partie ;
- ligne 94 : la fonction [savePart] enregistre une partie :
- si c'est une pièce jointe (disposition [attachment]), on l'enregistre avec la fonction [saveAttachment] sous son nom de fichier ;
- si c'est un texte [text/plain] ou [text/html], on l'enregistre avec la fonction [saveText] dans le fichier [message.txt] ou [message.html]. Les autres parties sont ignorées ;
- dans les deux cas, le contenu de la partie est demandé au serveur IMAP par la méthode [partie] du client ;
- ligne 119 : le contenu d'une partie est codé. Les fonctions [base64_decode] et [quoted_printable_decode] le décodent ;
- ligne 131 : le texte est décodé, puis converti en UTF-8 avec la fonction [mb_convert_encoding] s'il utilise un autre jeu de caractères (paramètre [charset]). On le fait précéder des entêtes [From], [To] et [Subject] du message. Dans le cas d'un texte HTML, chaque entête est suivi d'une balise [<br/>] pour être seul sur sa ligne dans un navigateur ;
- ligne 152 : une pièce jointe est décodée puis enregistrée .
Pour tester le script [imap-02.php], on envoie d'abord un mail à [guest@localhost] avec le script [smtp-02.php] (le message [test-localhost] et ses cinq pièces jointes), qu'on dépose dans la boîte INBOX du serveur IMAP. On le lit ensuite avec [imap-02.php]. Les résultats console sont les suivants :
| > php imap-02.php
------------Lecture de la boîte à lettres [localhost:993]
Connexion établie avec le serveur [localhost:993].
Il y a [3] messages dans la boîte à lettres [localhost:993]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:993]
-----contenu de la partie n° [0]
Content-Type: MULTIPART/MIXED
Transfer-Encoding : 7bit
-----contenu de la partie n° [1]
Content-Type: MULTIPART/ALTERNATIVE
Transfer-Encoding : 7bit
-----contenu de la partie n° [1.1]
Content-Type: TEXT/PLAIN
Transfer-Encoding : quoted-printable
Sauvegarde d'un message dans [output/localhost-imap/message-3/message.txt]
-----contenu de la partie n° [1.2]
Content-Type: TEXT/HTML
Transfer-Encoding : quoted-printable
Sauvegarde d'un message dans [output/localhost-imap/message-3/message.html]
-----contenu de la partie n° [2]
Content-Type: APPLICATION/VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT
Transfer-Encoding : base64
Sauvegarde de la pièce jointe dans [output/localhost-imap/message-3/Hello from Symfony Mailer.docx]
-----contenu de la partie n° [3]
Content-Type: APPLICATION/PDF
Transfer-Encoding : base64
Sauvegarde de la pièce jointe dans [output/localhost-imap/message-3/Hello from Symfony Mailer.pdf]
-----contenu de la partie n° [4]
Content-Type: APPLICATION/VND.OASIS.OPENDOCUMENT.TEXT
Transfer-Encoding : base64
Sauvegarde de la pièce jointe dans [output/localhost-imap/message-3/Hello from Symfony Mailer.odt]
-----contenu de la partie n° [5]
Content-Type: IMAGE/PNG
Transfer-Encoding : base64
Sauvegarde de la pièce jointe dans [output/localhost-imap/message-3/Cours-Tutoriels-Serge-Tahé-1568x268.png]
-----contenu de la partie n° [6]
Content-Type: MESSAGE/RFC822
Transfer-Encoding : base64
Sauvegarde de la pièce jointe dans [output/localhost-imap/message-3/test-localhost.eml]
Fermeture de la connexion réussie.
Terminé
|
On retrouve bien la structure du message. Les fichiers ont été enregistrés dans le dossier [output/localhost-imap/message-3] :
| message-3
├── Cours-Tutoriels-Serge-Tahé-1568x268.png
├── Hello from Symfony Mailer.docx
├── Hello from Symfony Mailer.odt
├── Hello from Symfony Mailer.pdf
├── message.html
├── message.txt
└── test-localhost.eml
|
Les documents Word, PDF, OpenOffice et l'image s'ouvrent normalement : ils ont été correctement décodés.
Le message joint [test-localhost.eml] a été enregistré comme une pièce jointe. Or c'est lui-même un mail qui, à son tour, a des textes et des pièces jointes. On remarquera dans la réponse [BODYSTRUCTURE] que le serveur n'a pas su analyser ce message joint, parce qu'il est codé en base64 : il en donne une enveloppe vide [(NIL NIL ...)] et une structure réduite à un texte vide. Pour aller plus loin, il faudrait télécharger le message joint, le décoder et analyser nous-mêmes sa structure MIME. Et ce message pourrait lui-même contenir un mail joint, etc. Plutôt que d'écrire cet analyseur, nous allons utiliser une bibliothèque.
16.6.8. Client POP3 / IMAP avec la bibliothèque [zbateson/mail-mime-parser]
Le cours PHP 7 utilisait la bibliothèque [php-mime-mail-parser], qui nécessitait l'extension PHP [mailparse], à télécharger et installer à part. Nous utiliserons la bibliothèque [zbateson/mail-mime-parser] (https://github.com/zbateson/mail-mime-parser), écrite entièrement en PHP : elle a été installée par la commande [composer install] avec les autres dépendances du cours, sans rien d'autre à faire. Elle analyse le texte complet d'un message, tel que le rendent les commandes [RETR] de POP3 et [FETCH n BODY[]] d'IMAP. Le script [imap-03.php] fonctionnera donc avec les deux protocoles.
Pour le tester, nous enverrons un message dont la dernière pièce jointe [test-localhost-2.eml] contient elle-même un mail joint [test-localhost.eml] :
- [test-localhost-2.eml] contient 4 pièces jointes (les documents Word, PDF, OpenOffice et l'image) et le mail [test-localhost.eml] ;
- [test-localhost.eml] contient les 4 mêmes pièces jointes .
Le fichier de configuration [config-smtp-03.json] décrit cet envoi :
| {
"mail to localhost via localhost": {
"smtp-server": "localhost",
"smtp-port": 1025,
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "test-localhost-3",
"message": "ligne 1\nligne 2\nligne 3",
"tls": false,
"attachments": [
"/attachments/Hello from Symfony Mailer.docx",
"/attachments/Hello from Symfony Mailer.pdf",
"/attachments/Hello from Symfony Mailer.odt",
"/attachments/Cours-Tutoriels-Serge-Tahé-1568x268.png",
"/attachments/test-localhost-2.eml"
]
}
}
|
On l'utilise avec la commande [php smtp-02.php config-smtp-03.json].
Le script [imap-03.php] utilise le même fichier de configuration [config-imap-01.json] que les précédents scripts. Il est le suivant :
| <?php
declare(strict_types=1);
// client IMAP / POP3 permettant de lire des mails
// l'analyse des messages est confiée à la bibliothèque [zbateson/mail-mime-parser]
// disponible à l'URL [https://github.com/zbateson/mail-mime-parser]
// dépendances
require_once __DIR__ . "/../../../vendor/autoload.php";
require_once __DIR__ . "/ImapClient.php";
require_once __DIR__ . "/Pop3Client.php";
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message;
// les paramètres de lecture du courrier
const CONFIG_FILE_NAME = "config-imap-01.json";
// on récupère la configuration
if (!file_exists(CONFIG_FILE_NAME)) {
print "Le fichier de configuration " . CONFIG_FILE_NAME . " n'existe pas\n";
exit(1);
}
$mailboxes = json_decode(file_get_contents(CONFIG_FILE_NAME), true);
// lecture des boîtes à lettres
foreach ($mailboxes as $name => $infos) {
if (($infos["enabled"] ?? true) === false) {
continue;
}
print "------------Lecture de la boîte à lettres [$name]\n";
readmailbox($name, $infos);
}
// fin
print "Terminé\n";
// --------------------------------------------------------------------------
function readmailbox(string $name, array $infos): void
{
// un client IMAP ou POP3 selon la configuration
$client = match ($infos["protocol"]) {
"imap" => new ImapClient($infos),
"pop3" => new Pop3Client($infos),
};
try {
// connexion
$nbmsg = $client->connecter();
print "Connexion établie avec le serveur [$name].\n";
print "Il y a [$nbmsg] messages dans la boîte à lettres [$name]\n";
// messages non lus
print "Récupération de la liste des messages non lus de la boîte à lettres [$name]\n";
$msgNumbers = $client->nonLus();
if (count($msgNumbers) === 0) {
print "Il n'y a pas de nouveaux messages dans la boîte à lettres [$name]\n";
}
// on parcourt la liste des messages non lus
foreach ($msgNumbers as $msgNumber) {
print "---message n° [$msgNumber]\n";
// on récupère le texte complet du message et on l'analyse
getMailBody($client->message($msgNumber), "{$infos["output-dir"]}/message-$msgNumber");
// si le protocole est POP3, on supprime le message après l'avoir récupéré
if ($infos["protocol"] === "pop3") {
$client->supprimer($msgNumber);
}
}
} catch (RuntimeException $ex) {
print "Erreur : " . $ex->getMessage() . "\n";
} finally {
try {
$client->fermer();
print "Fermeture de la connexion réussie.\n";
} catch (RuntimeException $ex) {
print "La fermeture de la connexion a échoué : " . $ex->getMessage() . "\n";
}
}
}
// --------------------------------------------------------------------------
// analyse du texte complet d'un message
function getMailBody(string $text, string $outputDir): void
{
// la bibliothèque analyse le texte du message et en fait un objet [IMessage]
// (false : le texte n'est plus utilisé une fois l'objet construit)
$message = Message::from($text, false);
// on sauvegarde les différentes parties du message dans le dossier [$outputDir]
getParts($message, $outputDir);
}
// --------------------------------------------------------------------------
// sauvegarde des différentes parties d'un message
function getParts(IMessage $message, string $outputDir): void
{
// on crée le dossier de sauvegarde du message si besoin est
if (!is_dir($outputDir) && !mkdir($outputDir, recursive: true)) {
print "Le dossier [$outputDir] n'a pu être créé\n";
return;
}
// les entêtes du message (from, to, subject...), décodés
$entêtes = [];
foreach ($message->getAllHeaders() as $header) {
$entêtes[] = "{$header->getName()}: {$header->getValue()}";
}
// on sauvegarde les messages texte
for ($i = 1; $i <= $message->getTextPartCount(); $i++) {
print "-- Sauvegarde d'un message de type [text/plain]\n";
saveMessage($message->getTextContent($i - 1), false, $entêtes, "$outputDir/message_$i.txt");
}
// on sauvegarde les messages html
for ($i = 1; $i <= $message->getHtmlPartCount(); $i++) {
print "-- Sauvegarde d'un message de type [text/html]\n";
saveMessage($message->getHtmlContent($i - 1), true, $entêtes, "$outputDir/message_$i.html");
}
// on parcourt la liste des pièces jointes
$iAttachment = 0;
foreach ($message->getAllAttachmentParts() as $attachment) {
// type et nom de la pièce jointe
$fileType = $attachment->getContentType();
$fileName = nomLibre($outputDir, $attachment->getFilename() ?? "piece-jointe");
print "-- Sauvegarde d'une pièce jointe de type [$fileType] dans le fichier [$fileName]\n";
// on sauvegarde la pièce jointe, décodée
$attachment->saveContent($fileName);
// cas particulier du type message/rfc822
if ($fileType === "message/rfc822") {
// la pièce jointe est elle-même un message - on l'analyse de façon récursive
// dans un sous-dossier [rfc822-1], [rfc822-2]...
$iAttachment++;
$attachedMessage = Message::from($attachment->getBinaryContentStream()->getContents(), false);
getParts($attachedMessage, "$outputDir/rfc822-$iAttachment");
}
}
}
// --------------------------------------------------------------------------
// un nom de fichier libre dans le dossier [$dir] : x.pdf, sinon x_1.pdf, x_2.pdf...
function nomLibre(string $dir, string $nom): string
{
$info = pathinfo($nom);
$extension = isset($info["extension"]) ? ".{$info["extension"]}" : "";
$candidat = "$dir/$nom";
for ($i = 1; file_exists($candidat); $i++) {
$candidat = "$dir/{$info["filename"]}_$i$extension";
}
return $candidat;
}
// --------------------------------------------------------------------------
// sauvegarde d'un message texte précédé des entêtes du message
function saveMessage(string $text, bool $html, array $entêtes, string $filename): void
{
// en HTML, chaque entête est terminé par <br/> pour être seul sur sa ligne
$finDeLigne = $html ? "<br/>\n" : "\n";
$contents = implode($finDeLigne, $entêtes) . $finDeLigne . $finDeLigne . $text;
if (file_put_contents($filename, $contents) === false) {
print "Le message n'a pu être sauvegardé dans le fichier [$filename]\n";
} else {
print "Le message a été sauvegardé dans le fichier [$filename]\n";
}
}
|
Commentaires :
- ligne 10 : le chargeur de classes de Composer, pour la bibliothèque [zbateson/mail-mime-parser] ;
- lignes 14-15 : les deux types de la bibliothèque que nous utilisons : l'interface [IMessage] et la classe [Message] qui l'implémente ;
- ligne 21 : on vérifie que le fichier de configuration existe ;
- ligne 39 : la fonction [readmailbox] a la même structure que dans [imap-01.php]. Le client IMAP ou POP3 est cette fois créé directement par une expression [match] ;
- ligne 61 : on demande le texte complet du message à la fonction [getMailBody] ;
- lignes 81-87 : la méthode statique [Message::from] analyse le texte du message et rend un objet [IMessage]. La fonction [getParts] sauvegarde alors ses différentes parties ;
- ligne 92 : la fonction [getParts] admet deux paramètres :
- le message [$message] à analyser ;
- [$outputDir] : le dossier dans lequel les textes et les pièces jointes du message doivent être sauvegardés ;
- lignes 101-102 : on récupère les entêtes du message en cours d'analyse (from, to, subject...). La méthode [getValue] rend la valeur décodée d'un entête ;
- lignes 105-107 : un message peut avoir plusieurs parties [text/plain]. La méthode [getTextContent($i - 1)] rend le texte de la i-ième (les parties sont numérotées à partir de 0), décodé et converti en UTF-8. On les sauvegarde toutes, en donnant à chacune un nom de fichier différent ;
- lignes 110-112 : idem pour les parties [text/html] ;
- ligne 116 : on parcourt la liste des pièces jointes du message ;
- lignes 118-119 : le type de la pièce jointe (entête Content-Type) et son nom, décodé par la bibliothèque, quelle que soit la façon dont il est codé. La fonction [nomLibre] rend un nom de fichier qui n'existe pas encore dans le dossier ;
- ligne 122 : la méthode [saveContent] enregistre le contenu décodé de la pièce jointe dans un fichier ;
- lignes 124-129 : si la pièce jointe est un mail, il est analysé à son tour, par un appel récursif à la fonction [getParts]. Son contenu décodé est obtenu par la méthode [getBinaryContentStream] qui rend un flux, dont on lit tout le contenu avec la méthode [getContents]. Si le message analysé a deux mails joints, le 1er sera sauvegardé dans le dossier [$outputDir/rfc822-1] et le second dans le dossier [$outputDir/rfc822-2] ;
- ligne 136 : si le fichier [x.pdf] existe déjà, la fonction [nomLibre] essaie les noms [x_1.pdf], [x_2.pdf]... jusqu'à trouver un nom de fichier qui n'existe pas. La fonction [pathinfo] découpe un nom de fichier en nom (filename) et extension (extension) ;
- ligne 149 : la fonction [saveMessage] sauvegarde un texte du message précédé des entêtes du message. Dans le cas d'un texte HTML, chaque entête est terminé par la balise [<br/>] .
On envoie le message avec la commande [php smtp-02.php config-smtp-03.json]. [Mailpit] le garde et nous le déposons aussi dans la boîte INBOX du serveur IMAP. Puis on exécute le script [imap-03.php]. Les affichages console sont les suivants :
| > php imap-03.php
------------Lecture de la boîte à lettres [localhost:993]
Connexion établie avec le serveur [localhost:993].
Il y a [4] messages dans la boîte à lettres [localhost:993]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:993]
---message n° [4]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-imap/message-4/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-imap/message-4/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-imap/message-4/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-imap/message-4/Cours-Tutoriels-Serge-Tahé-1568x268.png]
-- Sauvegarde d'une pièce jointe de type [message/rfc822] dans le fichier [output/localhost-imap/message-4/test-localhost-2.eml]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/rfc822-1/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/rfc822-1/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-imap/message-4/rfc822-1/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-imap/message-4/rfc822-1/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-imap/message-4/rfc822-1/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-imap/message-4/rfc822-1/Cours-Tutoriels-Serge-Tahé-1568x268.png]
-- Sauvegarde d'une pièce jointe de type [message/rfc822] dans le fichier [output/localhost-imap/message-4/rfc822-1/test-localhost.eml]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-imap/message-4/rfc822-1/rfc822-1/Cours-Tutoriels-Serge-Tahé-1568x268.png]
Fermeture de la connexion réussie.
------------Lecture de la boîte à lettres [localhost:1110]
Connexion établie avec le serveur [localhost:1110].
Il y a [1] messages dans la boîte à lettres [localhost:1110]
Récupération de la liste des messages non lus de la boîte à lettres [localhost:1110]
---message n° [1]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-pop3/message-1/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-pop3/message-1/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-pop3/message-1/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-pop3/message-1/Cours-Tutoriels-Serge-Tahé-1568x268.png]
-- Sauvegarde d'une pièce jointe de type [message/rfc822] dans le fichier [output/localhost-pop3/message-1/test-localhost-2.eml]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/rfc822-1/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/rfc822-1/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-pop3/message-1/rfc822-1/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-pop3/message-1/rfc822-1/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-pop3/message-1/rfc822-1/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-pop3/message-1/rfc822-1/Cours-Tutoriels-Serge-Tahé-1568x268.png]
-- Sauvegarde d'une pièce jointe de type [message/rfc822] dans le fichier [output/localhost-pop3/message-1/rfc822-1/test-localhost.eml]
-- Sauvegarde d'un message de type [text/plain]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/message_1.txt]
-- Sauvegarde d'un message de type [text/html]
Le message a été sauvegardé dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/message_1.html]
-- Sauvegarde d'une pièce jointe de type [application/vnd.openxmlformats-officedocument.wordprocessingml.document] dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/Hello from Symfony Mailer.docx]
-- Sauvegarde d'une pièce jointe de type [application/pdf] dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/Hello from Symfony Mailer.pdf]
-- Sauvegarde d'une pièce jointe de type [application/vnd.oasis.opendocument.text] dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/Hello from Symfony Mailer.odt]
-- Sauvegarde d'une pièce jointe de type [image/png] dans le fichier [output/localhost-pop3/message-1/rfc822-1/rfc822-1/Cours-Tutoriels-Serge-Tahé-1568x268.png]
Fermeture de la connexion réussie.
Terminé
|
Le message a été lu deux fois, par IMAP puis par POP3, et ses parties ont été enregistrées à chaque fois. Voici l'arborescence du dossier [output/localhost-imap/message-4] :
| message-4
├── rfc822-1
│ ├── rfc822-1
│ │ ├── Cours-Tutoriels-Serge-Tahé-1568x268.png
│ │ ├── Hello from Symfony Mailer.docx
│ │ ├── Hello from Symfony Mailer.odt
│ │ ├── Hello from Symfony Mailer.pdf
│ │ ├── message_1.html
│ │ └── message_1.txt
│ ├── Cours-Tutoriels-Serge-Tahé-1568x268.png
│ ├── Hello from Symfony Mailer.docx
│ ├── Hello from Symfony Mailer.odt
│ ├── Hello from Symfony Mailer.pdf
│ ├── message_1.html
│ ├── message_1.txt
│ └── test-localhost.eml
├── Cours-Tutoriels-Serge-Tahé-1568x268.png
├── Hello from Symfony Mailer.docx
├── Hello from Symfony Mailer.odt
├── Hello from Symfony Mailer.pdf
├── message_1.html
├── message_1.txt
└── test-localhost-2.eml
|
- au premier niveau, les textes et les 5 pièces jointes du mail reçu par [guest@localhost] ;
- dans le dossier [rfc822-1], les textes et les 5 pièces jointes du mail [test-localhost-2.eml] ;
- dans le dossier [rfc822-1/rfc822-1], les textes et les 4 pièces jointes du mail [test-localhost.eml] .
Si on visualise le fichier [rfc822-1/rfc822-1/message_1.html] dans un navigateur, on obtient la chose suivante :

On y trouve les entêtes du mail [test-localhost.eml], puis son texte, avec ses caractères accentués correctement décodés.
L'utilisation de la bibliothèque [zbateson/mail-mime-parser] facilite considérablement l'écriture du script de lecture de mails.