Creazione di un client di posta elettronica IMAP con PHP
Pubblicato: 2022-03-11Gli sviluppatori a volte si imbattono in attività che richiedono l'accesso alle caselle di posta elettronica. Nella maggior parte dei casi, questo viene fatto utilizzando l'Internet Message Access Protocol o IMAP. Come sviluppatore PHP, mi sono rivolto per la prima volta alla libreria IMAP integrata di PHP, ma questa libreria presenta bug ed è impossibile da eseguire il debug o la modifica. Inoltre, non è possibile personalizzare i comandi IMAP per sfruttare appieno le capacità del protocollo.
Quindi oggi creeremo un client di posta elettronica IMAP funzionante da zero utilizzando PHP. Vedremo anche come utilizzare i comandi speciali di Gmail.
Implementeremo IMAP in una classe personalizzata, imap_driver . Spiegherò ogni passaggio durante la creazione della classe. Puoi scaricare l'intero imap_driver.php alla fine dell'articolo.
Stabilire una connessione
IMAP è un protocollo basato sulla connessione e in genere opera su TCP/IP con sicurezza SSL, quindi prima di poter effettuare chiamate IMAP dobbiamo aprire la connessione.
Abbiamo bisogno di conoscere l'URL e il numero di porta del server IMAP a cui vogliamo connetterci. Queste informazioni sono generalmente pubblicizzate nel sito Web o nella documentazione del servizio. Ad esempio, per Gmail, l'URL è ssl://imap.gmail.com sulla porta 993.
Poiché vogliamo sapere se l'inizializzazione ha avuto successo, lasceremo vuoto il nostro costruttore di classi e tutte le connessioni verranno effettuate in un metodo init() personalizzato, che restituirà false se non è possibile stabilire la connessione:
class imap_driver { private $fp; // file pointer public $error; // error message ... public function init($host, $port) { if (!($this->fp = fsockopen($host, $port, $errno, $errstr, 15))) { $this->error = "Could not connect to host ($errno) $errstr"; return false; } if (!stream_set_timeout($this->fp, 15)) { $this->error = "Could not set timeout"; return false; } $line = fgets($this->fp); // discard the first line of the stream return true; } private function close() { fclose($this->fp); } ... } Nel codice sopra, ho impostato un timeout di 15 secondi, sia per fsockopen() per stabilire la connessione, sia per il flusso di dati stesso per rispondere alle richieste una volta aperto. È importante avere un timeout per ogni chiamata alla rete perché, abbastanza spesso, il server non risponde e dobbiamo essere in grado di gestire un tale blocco.
Prendo anche la prima riga del flusso e la ignoro. Di solito questo è solo un messaggio di saluto dal server o una conferma che è connesso. Controlla la documentazione del tuo particolare servizio di posta per assicurarti che sia così.
Ora vogliamo eseguire il codice sopra per vedere che init() ha successo:
include("imap_driver.php"); // test for init() $imap_driver = new imap_driver(); if ($imap_driver->init('ssl://imap.gmail.com', 993) === false) { echo "init() failed: " . $imap_driver->error . "\n"; exit; }Sintassi IMAP di base
Ora che abbiamo un socket attivo aperto sul nostro server IMAP, possiamo iniziare a inviare comandi IMAP. Diamo un'occhiata alla sintassi IMAP.
La documentazione formale può essere trovata in Internet Engineering Task Force (IETF) RFC3501. Le interazioni IMAP in genere consistono nell'invio di comandi da parte del client e nella risposta del server con un'indicazione di esito positivo, insieme a qualsiasi dato possa essere stato richiesto.
La sintassi di base per i comandi è:
line_number command arg1 arg2 ...Il numero di riga, o "tag", è un identificatore univoco per il comando, che il server utilizza per indicare a quale comando sta rispondendo se sta elaborando più comandi contemporaneamente.
Ecco un esempio, che mostra il comando LOGIN :
00000001 LOGIN [email protected] passwordLa risposta del server può iniziare con una risposta dati "senza tag". Ad esempio, Gmail risponde a un accesso riuscito con una risposta senza tag contenente informazioni sulle capacità e le opzioni del server e un comando per recuperare un messaggio di posta elettronica riceverà una risposta senza tag contenente il corpo del messaggio. In entrambi i casi, una risposta deve sempre terminare con una riga di risposta di completamento del comando "contrassegnata", che identifica il numero di riga del comando a cui si applica la risposta, un indicatore dello stato di completamento e eventuali metadati aggiuntivi sul comando:
line_number status metadata1 metadata2 ... Ecco come risponde Gmail al comando LOGIN :
- Successo:
* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS 00000001 OK [email protected] authenticated (Success)- Fallimento:
00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure) Lo stato può essere OK , che indica successo, NO , che indica un errore o BAD , che indica un comando non valido o una sintassi errata.
Implementazione dei comandi di base:
Creiamo una funzione per inviare un comando al server IMAP e recuperare la risposta e la riga finale:
class imap_driver { private $command_counter = "00000001"; public $last_response = array(); public $last_endline = ""; private function command($command) { $this->last_response = array(); $this->last_endline = ""; fwrite($this->fp, "$this->command_counter $command\r\n"); // send the command while ($line = fgets($this->fp)) { // fetch the response one line at a time $line = trim($line); // trim the response $line_arr = preg_split('/\s+/', $line, 0, PREG_SPLIT_NO_EMPTY); // split the response into non-empty pieces by whitespace if (count($line_arr) > 0) { $code = array_shift($line_arr); // take the first segment from the response, which will be the line number if (strtoupper($code) == $this->command_counter) { $this->last_endline = join(' ', $line_arr); // save the completion response line to parse later break; } else { $this->last_response[] = $line; // append the current line to the saved response } } else { $this->last_response[] = $line; } } $this->increment_counter(); } private function increment_counter() { $this->command_counter = sprintf('%08d', intval($this->command_counter) + 1); } ... } Il comando LOGIN
Ora possiamo scrivere funzioni per comandi specifici che chiamano sotto il cofano la nostra funzione command() . Scriviamo una funzione per il comando LOGIN :
class imap_driver { ... public function login($login, $pwd) { $this->command("LOGIN $login $pwd"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }Ora possiamo testarlo in questo modo. (Tieni presente che devi disporre di un account e-mail attivo con cui eseguire il test.)
... // test for login() if ($imap_driver->login('[email protected]', 'password') === false) { echo "login() failed: " . $imap_driver->error . "\n"; exit; }Tieni presente che Gmail è molto rigoroso sulla sicurezza per impostazione predefinita: non ci consentirà di accedere a un account di posta elettronica con IMAP se disponiamo di impostazioni predefinite e proviamo ad accedervi da un paese diverso dal paese del profilo dell'account. Ma è abbastanza facile da risolvere; imposta semplicemente le impostazioni meno sicure nel tuo account Gmail, come descritto qui.

Il comando SELECT
Ora vediamo come selezionare una cartella IMAP per fare qualcosa di utile con la nostra email. La sintassi è simile a quella di LOGIN , grazie al nostro metodo command() . Usiamo invece il comando SELECT e specifichiamo la cartella.
class imap_driver { ... public function select_folder($folder) { $this->command("SELECT $folder"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }Per testarlo, proviamo a selezionare la INBOX:
... // test for select_folder() if ($imap_driver->select_folder("INBOX") === false) { echo "select_folder() failed: " . $imap_driver->error . "\n"; return false; }Implementazione di comandi avanzati
Diamo un'occhiata a come implementare alcuni dei comandi più avanzati di IMAP.
Il comando SEARCH
Una routine comune nell'analisi delle e-mail consiste nel cercare e-mail in un determinato intervallo di date o cercare e-mail contrassegnate e così via. I criteri di ricerca devono essere passati al comando SEARCH come argomento, con spazio come separatore. Ad esempio, se vogliamo ricevere tutte le email dal 20 novembre 2015, dobbiamo passare il seguente comando:
00000005 SEARCH SINCE 20-Nov-2015E la risposta sarà qualcosa del genere:
* SEARCH 881 882 00000005 OK SEARCH completed La documentazione dettagliata dei possibili termini di ricerca può essere trovata qui L'output di un comando SEARCH è un elenco di UID di e-mail, separati da spazi bianchi. Un UID è un identificatore univoco di un'e-mail nell'account dell'utente, in ordine cronologico, dove 1 è l'e-mail più vecchia. Per implementare il comando SEARCH dobbiamo semplicemente restituire gli UID risultanti:
class imap_driver { ... public function get_uids_by_search($criteria) { $this->command("SEARCH $criteria"); if (preg_match('~^OK~', $this->last_endline) && is_array($this->last_response) && count($this->last_response) == 1) { $splitted_response = explode(' ', $this->last_response[0]); $uids = array(); foreach ($splitted_response as $item) { if (preg_match('~^\d+$~', $item)) { $uids[] = $item; // put the returned UIDs into an array } } return $uids; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }Per testare questo comando, riceveremo e-mail degli ultimi tre giorni:
... // test for get_uids_by_search() $ids = $imap_driver->get_uids_by_search('SINCE ' . date('jM-Y', time() - 60 * 60 * 24 * 3)); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; exit; } Il comando FETCH con BODY.PEEK
Un'altra attività comune è ottenere le intestazioni delle e-mail senza contrassegnare un'e-mail come SEEN . Dal manuale IMAP, il comando per recuperare tutta o parte di un'e-mail è FETCH . Il primo argomento indica a quale parte siamo interessati e in genere viene passato BODY , che restituirà l'intero messaggio insieme alle sue intestazioni e lo contrassegnerà come SEEN . L'argomento alternativo BODY.PEEK farà la stessa cosa, senza contrassegnare il messaggio come SEEN .
La sintassi IMAP richiede che la nostra richiesta specifichi, tra parentesi quadre, anche la sezione dell'e-mail che vogliamo recuperare, che in questo esempio è [HEADER] . Di conseguenza, il nostro comando sarà simile a questo:
00000006 FETCH 2 BODY.PEEK[HEADER]E ci aspetteremo una risposta simile a questa:
* 2 FETCH (BODY[HEADER] {438} MIME-Version: 1.0 x-no-auto-attachment: 1 Received: by 10.170.97.214; Fri, 30 May 2014 09:13:45 -0700 (PDT) Date: Fri, 30 May 2014 09:13:45 -0700 Message-ID: <CACYy8gU+UFFukbE0Cih8kYRENMXcx1DTVhvg3TBbJ52D8OF6nQ@mail.gmail.com> Subject: The best of Gmail, wherever you are From: Gmail Team <[email protected]> To: Example Test <[email protected]> Content-Type: multipart/alternative; boundary=001a1139e3966e26ed04faa054f4 ) 00000006 OK SuccessPer creare una funzione per il recupero delle intestazioni, dobbiamo essere in grado di restituire la risposta in una struttura hash (coppie chiave/valore):
class imap_driver { ... public function get_headers_from_uid($uid) { $this->command("FETCH $uid BODY.PEEK[HEADER]"); if (preg_match('~^OK~', $this->last_endline)) { array_shift($this->last_response); // skip the first line $headers = array(); $prev_match = ''; foreach ($this->last_response as $item) { if (preg_match('~^([az][a-z0-9-_]+):~is', $item, $match)) { $header_name = strtolower($match[1]); $prev_match = $header_name; $headers[$header_name] = trim(substr($item, strlen($header_name) + 1)); } else { $headers[$prev_match] .= " " . $item; } } return $headers; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }E per testare questo codice basta specificare l'UID del messaggio che ci interessa:
... // test for get_headers_by_uid if (($headers = $imap_driver->get_headers_from_uid(2)) === false) { echo "get_headers_by_uid() failed: " . $imap_driver->error . "\n"; return false; }Estensioni IMAP di Gmail
Gmail fornisce un elenco di comandi speciali che possono semplificarci la vita. L'elenco dei comandi di estensione IMAP di Gmail è disponibile qui. Esaminiamo un comando che, secondo me, è il più importante: X-GM-RAW . Ci consente di utilizzare la sintassi di ricerca di Gmail con IMAP. Ad esempio, possiamo cercare e-mail che si trovano nelle categorie Principale, Sociale, Promozioni, Aggiornamenti o Forum.
Funzionalmente, X-GM-RAW è un'estensione del comando SEARCH , quindi possiamo riutilizzare il codice che abbiamo sopra per il comando SEARCH . Tutto quello che dobbiamo fare è aggiungere la parola chiave X-GM-RAW e i criteri:
... // test for gmail extended search functionality $ids = $imap_driver->get_uids_by_search(' X-GM-RAW "category:primary"'); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; return false; }Il codice precedente restituirà tutti gli UID elencati nella categoria "Principale".
Nota: a partire da dicembre 2015, Gmail confonde spesso la categoria "Principale" con la categoria "Aggiornamenti" su alcuni account. Questo è un bug di Gmail che non è stato ancora corretto.
Conclusione
Nel complesso, l'approccio socket personalizzato offre maggiore libertà allo sviluppatore. Consente di implementare tutti i comandi in IMAP RFC3501. Ti darà anche un migliore controllo sul tuo codice, dal momento che non devi chiederti cosa sta succedendo "dietro le quinte".
La classe imap_driver completa che abbiamo implementato in questo articolo può essere trovata qui. Può essere utilizzato così com'è e ci vorranno solo pochi minuti perché uno sviluppatore scriva una nuova funzione o richieda al proprio server IMAP. Ho anche incluso una funzionalità di debug nella classe per un output dettagliato.
