Creación de un cliente de correo electrónico IMAP con PHP

Publicado: 2022-03-11

Los desarrolladores a veces se encuentran con tareas que requieren acceso a buzones de correo electrónico. En la mayoría de los casos, esto se hace mediante el Protocolo de acceso a mensajes de Internet o IMAP. Como desarrollador de PHP, primero recurrí a la biblioteca IMAP integrada de PHP, pero esta biblioteca tiene errores y es imposible depurar o modificar. Tampoco es posible personalizar los comandos IMAP para aprovechar al máximo las capacidades del protocolo.

Así que hoy crearemos un cliente de correo electrónico IMAP que funcione desde cero usando PHP. También veremos cómo usar los comandos especiales de Gmail.

Implementaremos IMAP en una clase personalizada, imap_driver . Explicaré cada paso mientras construyo la clase. Puede descargar todo el imap_driver.php al final del artículo.

Establecer una conexión

IMAP es un protocolo basado en conexión y normalmente funciona sobre TCP/IP con seguridad SSL, por lo que antes de que podamos realizar llamadas IMAP, debemos abrir la conexión.

Necesitamos saber la URL y el número de puerto del servidor IMAP al que queremos conectarnos. Esta información generalmente se anuncia en el sitio web o en la documentación del servicio. Por ejemplo, para Gmail, la URL es ssl://imap.gmail.com en el puerto 993.

Como queremos saber si la inicialización fue exitosa, dejaremos nuestro constructor de clases vacío y todas las conexiones se realizarán en un método init() personalizado, que devolverá false si no se puede establecer la conexión:

 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); } ... }

En el código anterior, establecí un tiempo de espera de 15 segundos, tanto para que fsockopen() establezca la conexión como para que el flujo de datos responda a las solicitudes una vez que esté abierto. Es importante tener un tiempo de espera para cada llamada a la red porque, con bastante frecuencia, el servidor no responde y debemos ser capaces de manejar esa congelación.

También tomo la primera línea de la transmisión y la ignoro. Por lo general, esto es solo un mensaje de saludo del servidor o una confirmación de que está conectado. Verifique la documentación de su servicio de correo en particular para asegurarse de que este sea el caso.

Ahora queremos ejecutar el código anterior para ver que init() es exitoso:

 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; }

Sintaxis básica de IMAP

Ahora que tenemos un socket activo abierto a nuestro servidor IMAP, podemos comenzar a enviar comandos IMAP. Echemos un vistazo a la sintaxis de IMAP.

La documentación formal se puede encontrar en Internet Engineering Task Force (IETF) RFC3501. Las interacciones IMAP generalmente consisten en que el cliente envía comandos y el servidor responde con una indicación de éxito, junto con cualquier información que se haya solicitado.

La sintaxis básica de los comandos es:

 line_number command arg1 arg2 ...

El número de línea, o "etiqueta", es un identificador único para el comando, que el servidor usa para indicar a qué comando está respondiendo en caso de que esté procesando varios comandos a la vez.

Aquí hay un ejemplo que muestra el comando LOGIN :

 00000001 LOGIN [email protected] password

La respuesta del servidor puede comenzar con una respuesta de datos "sin etiquetar". Por ejemplo, Gmail responde a un inicio de sesión exitoso con una respuesta sin etiquetar que contiene información sobre las capacidades y opciones del servidor, y un comando para buscar un mensaje de correo electrónico recibirá una respuesta sin etiquetar que contiene el cuerpo del mensaje. En cualquier caso, una respuesta siempre debe terminar con una línea de respuesta de finalización de comando "etiquetada", identificando el número de línea del comando al que se aplica la respuesta, un indicador de estado de finalización y metadatos adicionales sobre el comando, si los hay:

 line_number status metadata1 metadata2 ...

Así es como Gmail responde al comando LOGIN :

  • Éxito:
 * 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)
  • Falla:
 00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)

El estado puede ser OK , que indica éxito, NO , que indica falla o BAD , que indica un comando no válido o una sintaxis incorrecta.

Implementando Comandos Básicos:

Hagamos una función para enviar un comando al servidor IMAP y recuperar la respuesta y la línea final:

 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); } ... }

El comando INICIO DE LOGIN

Ahora podemos escribir funciones para comandos específicos que llamen a nuestra función command() bajo el capó. Escribamos una función para el 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; } } ... }

Ahora podemos probarlo así. (Tenga en cuenta que debe tener una cuenta de correo electrónico activa para realizar la prueba).

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

Tenga en cuenta que Gmail es muy estricto con la seguridad por defecto: no nos permitirá acceder a una cuenta de correo con IMAP si tenemos la configuración predeterminada e intentamos acceder desde un país que no sea el país del perfil de la cuenta. Pero es bastante fácil de arreglar; solo establezca configuraciones menos seguras en su cuenta de Gmail, como se describe aquí.

El comando SELECT

Ahora veamos cómo seleccionar una carpeta IMAP para hacer algo útil con nuestro correo electrónico. La sintaxis es similar a la de LOGIN , gracias a nuestro método command() . En su lugar, usamos el comando SELECT y especificamos la carpeta.

 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; } } ... }

Para probarlo, intentemos seleccionar INBOX:

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

Implementando Comandos Avanzados

Veamos cómo implementar algunos de los comandos más avanzados de IMAP.

El comando SEARCH

Una rutina común en el análisis de correo electrónico es buscar correos electrónicos en un intervalo de fechas determinado, o buscar correos electrónicos marcados, etc. Los criterios de búsqueda deben pasarse al comando SEARCH como argumento, con un espacio como separador. Por ejemplo, si queremos obtener todos los correos electrónicos desde el 20 de noviembre de 2015, debemos pasar el siguiente comando:

 00000005 SEARCH SINCE 20-Nov-2015

Y la respuesta será algo como esto:

 * SEARCH 881 882 00000005 OK SEARCH completed

La documentación detallada de los posibles términos de búsqueda se puede encontrar aquí. El resultado de un comando SEARCH es una lista de UID de correos electrónicos, separados por espacios en blanco. Un UID es un identificador único de un correo electrónico en la cuenta del usuario, en orden cronológico, donde 1 es el correo electrónico más antiguo. Para implementar el comando SEARCH simplemente debemos devolver los UID resultantes:

 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; } } ... }

Para probar este comando, recibiremos correos electrónicos de los últimos tres días:

 ... // 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; }

El comando FETCH con BODY.PEEK

Otra tarea común es obtener encabezados de correo electrónico sin marcar un correo electrónico como SEEN . Del manual de IMAP, el comando para recuperar todo o parte de un correo electrónico es FETCH . El primer argumento indica qué parte nos interesa y, por lo general, se pasa BODY , que devolverá el mensaje completo junto con sus encabezados y lo marcará como SEEN . El argumento alternativo BODY.PEEK hará lo mismo, sin marcar el mensaje como SEEN .

La sintaxis de IMAP requiere que nuestra solicitud también especifique, entre corchetes, la sección del correo electrónico que queremos obtener, que en este ejemplo es [HEADER] . Como resultado, nuestro comando se verá así:

 00000006 FETCH 2 BODY.PEEK[HEADER]

Y esperaremos una respuesta que se vea así:

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

Para construir una función para obtener encabezados, debemos poder devolver la respuesta en una estructura hash (pares clave/valor):

 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; } } ... }

Y para probar este código solo especificamos el UID del mensaje que nos interesa:

 ... // 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; }

Extensiones IMAP de Gmail

Gmail proporciona una lista de comandos especiales que pueden hacernos la vida mucho más fácil. La lista de comandos de extensión IMAP de Gmail está disponible aquí. Repasemos un comando que, en mi opinión, es el más importante: X-GM-RAW . Nos permite utilizar la sintaxis de búsqueda de Gmail con IMAP. Por ejemplo, podemos buscar correos electrónicos que estén en las categorías Principal, Social, Promociones, Actualizaciones o Foros.

Funcionalmente, X-GM-RAW es una extensión del comando SEARCH , por lo que podemos reutilizar el código que tenemos arriba para el comando SEARCH . Todo lo que tenemos que hacer es agregar la palabra clave X-GM-RAW y los criterios:

 ... // 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; }

El código anterior devolverá todos los UID que se enumeran en la categoría "Principal".

Nota: a partir de diciembre de 2015, Gmail a menudo confunde la categoría "Principal" con la categoría "Actualizaciones" en algunas cuentas. Este es un error de Gmail que aún no se ha solucionado.

Conclusión

Tienes un nuevo correo. ¿Ahora que? Lea cómo crear un cliente de correo electrónico IMAP personalizado en PHP y verifique el correo según sus términos.
Pío

En general, el enfoque de socket personalizado brinda más libertad al desarrollador. Permite implementar todos los comandos en IMAP RFC3501. También le dará un mejor control sobre su código, ya que no tiene que preguntarse qué sucede "detrás de escena".

La clase imap_driver completa que implementamos en este artículo se puede encontrar aquí. Se puede usar tal cual, y un desarrollador solo tardará unos minutos en escribir una nueva función o solicitud en su servidor IMAP. También incluí una función de depuración en la clase para una salida detallada.