Recibir correo POP3

Correo

Ya vimos en una entrada anterior cómo podemos enviar mensajes de correo desde una aplicación Windows. En esta entrada veremos cómo podemos recibir correo usando el protocolo POP3.

Explicaré algo de teoría (no mucha), pero no te preocupes, verás que leer correos será realmente sencillo, otra tema muy diferente será procesar su contenido.

Antes que nada, necesitaremos varias cosas para poder recibir correo:

  • Lo primero, por supuesto, es una cuenta de correo POP3. Puede servir, por ejemplo, una de Gmail. (Ver notas al final para ver una explicación de cómo se activa ese protocolo en Gmail).
  • Conocer el protocolo POP3, ya que lo usaremos directamente en nuestro programa.
  • Debido a que hay que tener ciertas medidas de seguridad, será necesario conseguir una librería para soporte del protocolo SSL.

El protocolo POP3

Se trata de un protocolo realmente simple. Si te interesa conocer más detalles, consulta esta entrada de la Wikipedia.

Lo que nos interesa ahora son las órdenes o comandos disponibles en ese protocolo. Como son pocas, las veremos todas.

  • USER <nombre> Identificación de usuario (Solo se realiza una vez).
  • PASS <password> Envía la clave del servidor.
  • STAT Da el número de mensajes no borrados en el buzón y su longitud total.
  • RETR <número> Solicita el envío del mensaje especificando el número (no se borra del buzón).
  • LIST Muestra todos los mensajes no borrados con su longitud.
  • NOOP Permite mantener la conexión abierta en caso de inactividad.
  • TOP <número> <líneas> Muestra la cabecera y el número de líneas requerido del mensaje especificando el número.
  • DELE <número> Borra el mensaje especificando el número.
  • RSET Recupera los mensajes borrados (en la conexión actual).
  • UIDL <número> Devuelve una cadena identificatoria del mensaje persistente a través de las sesiones. Si no se especifica <número> se devuelve una lista con los números de mensajes y su cadena identificatoria de los mensajes no borrados.
  • QUIT Salir.

Una sesión POP3 sigue este esquema:

  1. Establecer una conexión con el servidor, se puede usar telnet, o sockets. Nosotros usaremos una librería, de modo que esta parte es transparente en nuestro caso.
  2. Identificarse, usando el comando USER.
  3. Enviar la contraseña, usando el comando PASS.
  4. Ahora, los comandos a utilizar dependerán de lo que queramos hacer. Si sólo nos interesa saber si hay correo pendiente de leer, podemos usar el comando STAT, que nos devuelve el número total de mensajes y su tamaño en conjunto. Si queremos recuperar los mensajes, tendremos que usar el comando LIST para obtener una lista de los mensajes disponibles, con su número y tamaño, y el comando RETR para recuperar cada uno de ellos. Opcionalmente podemos borrarlos después de descargarlos, usando DELE.
  5. Cerrar la sesión, usando el comando QUIT.

Establecer una conexión segura SSL

El protocolo POP3 no es seguro. Aunque haya que identificarse, las claves se transmiten por la red en forma de texto, por lo que es posible que sean interceptadas.

Para evitar esto, lo normal es usar POP3 sobre SSL, puedes informarte sobre ello en Wikipedia.

Afortunadamente para nosotros, no tenemos que preocuparnos demasiado por esta capa, ya que hay librerías en Internet que permiten conectarse a servidores usando SSL.

En concreto, usaremos OpenSSL, cuya página oficial es http://www.openssl.org.

Y más concretamente, para Windows, usaremos el paquete que se puede descargar desde http://gnuwin32.sourceforge.net/packages/openssl.htm, en esa página descargaremos el primer fichero, "Complete package, except sources", que es un ejecutable que contiene la documentación, bibliotecas dinámicas, estáticas y ficheros de cabecera.

Como con otras librerías, copiaremos cada fichero en el lugar correspondiente:

  • Los ficheros de cabecera, que están en la carpeta "openssl", dentro de la carpeta "include", los copiaremos a la carpeta "include" de nuestro compilador. Copiaremos la carpeta completa "openssl", no sólo los ficheros de cabecera.
  • Los ficheros de biblioteca estática, con la extensión ".a", que están en la carpeta "lib", los copiaremos a la carpeta "lib" de nuestro compilador.
  • Los ficheros de biblioteca de enlace dinámico (DLL), que están en la carpeta "bin", los copiaremos a la carpeta de trabajo de nuestra aplicación, o en un lugar donde sean localizables por el sistema.

Funciones de inicio

Las librerías de OpenSSL disponen de muchas funciones, pero la mayor parte de ellas no las vamos a necesitar para obtener mensajes. Entre ellas usaremos las siguientes:

SSL_library_init()
Debe ser invocada antes que tenga lugar cualquier otra acción. Registra algunos de los algoritmos necesarios para usar SSL.
SSL_load_error_strings()
Registra los mensajes de error.
ERR_load_BIO_strings()
Registra los mensajes de la librería BIO.
OpenSSL_add_all_algorithms()
Registra y crea una tabla interna de algoritmos SSL.
SSL_CTX
Objeto creado como un marco para establecer conexiones TLS/SSL. Varias opciones, como certificados, algoritmos, etc, se pueden asignar mediante este objeto.
SSL_CTX_new()
Crea un objeto SSL_CTX, el valor de retorno es un puntero a un objeto de este tipo.
SSLv23_client_method()
El valor de retorno de esta función se usa como argumento de la anterior.
BIO
Objeto para establecer conexiones.
BIO_new_ssl_connect()
Establece una conexión y devuelve un puntero a un objeto BIO. Como argumento se usa un objeto de tipo SSL_CTX.
SSL
Objeto para conexión segura.
BIO_get_ssl()
recupera el puntero SSL de BIO, que puede ser entonces manipulado usando SSL estándar. Como primer argumento se debe especificar un objeto BIO, y como segundo un objeto SSL.
SSL_set_mode()
Asigna el modo a un SSL. El primer argumento es un puntero a un objeto SSL, el segundo el modo.

Todas estas funciones son sólo para preparar la conexión. No te preocupes si no está claro cómo funcionan en conjunto (ni por separado), el bloque de código para iniciar SSL queda como sigue:

    SSL *tunelSSL;
    BIO *conexionBIO;
    SSL_CTX *datosConexion;

    /* Inicializacion */
    SSL_library_init();
    SSL_load_error_strings();
    ERR_load_BIO_strings();
    OpenSSL_add_all_algorithms();
    datosConexion=SSL_CTX_new(SSLv23_client_method());
    conexionBIO = BIO_new_ssl_connect(datosConexion);
    BIO_get_ssl(conexionBIO, &tunelSSL);
    SSL_set_mode(tunelSSL, SSL_MODE_AUTO_RETRY);

Funciones de cierre

De forma simétrica, cuando terminemos la tarea, deberemos cerrarlo todo. Para ello usaremos las siguientes funciones:

BIO_reset()
Retorna el objeto BIO, cuyo puntero se pasa como argumento, a su estado inicial.
BIO_free_all()
Libera toda la cadena BIO, de nuevo pasamos un puntero al objeto BIO como argumento.
SSL_CTX_free()
Libera el objeto SSL_CTX cuyo puntero se pasa como argumento.

El proceso de cierre queda así:

    BIO_reset(conexionBIO);
    BIO_free_all(conexionBIO);
    SSL_CTX_free(datosConexion);

Funciones de conexión, lectura y escritura

Entre la inicialización y el cierre es donde haremos nuestra tarea, usando el protocolo POP3.

Primero, indicamos con qué servidor queremos establecer una conexión, usando la función BIO_set_conn_hostname. El primer argumento es un puntero a un objeto BIO, que hemos obtenido en la inicialización. El segundo es una cadena con el nombre y puerto del servidor. En este ejemplo, el de Gmail.

    BIO_set_conn_hostname(conexionBIO,"pop.gmail.com:995");

Para establecer la conexión se usa la función BIO_do_connect, indicando como argumento un puntero a un objeto BIO (el mismo objeto). Si el valor de retorno es menor o igual a cero, es que no se ha podido establecer la conexión.

    if(BIO_do_connect(conexionBIO) <= 0) cout << "ERROR: No es posible crear la conexion." << endl;

En este punto estamos conectados al servidor, y recibiremos un mensaje desde él. Para recuperarlo usamos la función BIO_read, indicando en el primer parámetro el puntero al objeto BIO, en el segundo la dirección del buffer de entrada, y en el tercero, el tamaño máximo de bytes a leer. El valor de retorno es el número de bytes leídos.

    int nbytes=0;
    char BufferDeSalida[512];
    char BufferDeEntrada[1025];

    nbytes=BIO_read(conexionBIO, BufferDeEntrada, 1024);
    BufferDeEntrada[nbytes] = 0;
    cout << BufferDeEntrada << endl;

Recibiremos un mensaje de bienvenida, cuyo texto depende del servidor. Aunque siempre empezará por "+OK". En el caso de Gmail tiene esta forma:

+OK Gpop ready for requests from nnn.nnn.nnn.nnn <id>

Básicamente indica que está esperando a que nos identifiquemos. Para eso, lo primero es enviar nuestro nombre de usuario, usando el protocolo POP3, es decir, con el comando "USER". Usaremos la función BIO_write, indicando como primer argumento el puntero a la estructura BIO, como segundo la dirección de la cadena con el comando, terminada en '\n', y como tercero, la longitud de la cadena. El servidor responderá con otra cadena de reconocimiento o de error, dependiendo de si el usuario es reconocido o no.

    strcpy(BufferDeSalida,"USER usuario@gmail.com\n"); // Cambia la cadena según  tu caso
    BIO_write(conexionBIO, BufferDeSalida, strlen(BufferDeSalida));

    nbytes=BIO_read(conexionBIO, BufferDeEntrada,sizeof(BufferDeEntrada));
    BufferDeEntrada[nbytes] = 0;
    cout << BufferDeEntrada << endl;

La respuesta será de este tipo, más o menos:

+OK send PASS

Ahora hay que enviar la contraseña, usando el comando "PASS".

    strcpy(BufferDeSalida,"PASS contraseña\n");
    BIO_write(conexionBIO, BufferDeSalida, strlen(BufferDeSalida));

    nbytes=BIO_read(conexionBIO, BufferDeEntrada, 1024);
    BufferDeEntrada[nbytes] = 0;
    cout << BufferDeEntrada << endl;

En este caso, la respuesta, si el usuario y contraseña son correctos, será de este tipo:

+OK Welcome.

En caso de no ser reconocido, será un mensaje de error:

-ERR [AUTH] Username and password not accepted.

Es el momento de empezar a comunicarse con el servidor de correo para recuperar información.

Podemos, por ejemplo, obtener el número de mensajes pendientes de leer, usando el comando "STAT". Como respuesta obtendremos una cadena con el formato:

+OK <n> <tamaño>

Donde <n> es el número de mensajes y <tamaño> el número de bytes que ocupan todos los mensajes en total.

Podemos usar el comando "LIST" para obtener una lista de los mensajes, cada uno con su tamaño. Como respuesta obtendremos un mensaje de varias líneas, de este tipo:

+OK 5 messages:
1 31052
2 7460
3 12834
4 13749
5 45141
.

Observa que cuando la respuesta tiene más de una línea, la última contiene un punto.

Hay que tener cuidado cuando las repuestas pueden ser muy largas. Es el caso del comando "LIST" y de "RETR". Las lecturas de respuestas largas pueden requerir llamar a la función BIO_read varias veces, porque el buffer de entrada no será lo bastante grande para leer la respuesta completa.

Otro problema es que no podemos conocer con antelación el tamaño de la respuesta que tenemos que leer. En el caso del comando "LIST", sabemos el número de líneas, pero no el tamaño de cada una. En el caso del comando "RETR", sabemos el tamaño del mensaje, pero no podemos tener una confianza ciega en ese dato.

Por ejemplo, me he encontrado con que algunos mensajes de Gmail son mucho más largos que lo que se indica en la respuesta al comando "LIST".

Hay dos posibles soluciones que se pueden aplicar a este problema:

  1. Crear un buffer de lectura tan grande que garantice que tengamos espacio suficiente.
  2. Agrandar el buffer cada vez que se detecte que una lectura producirá un desbordamiento.

En el caso de lecturas de respuestas a la orden "LIST", podemos asumir un tamaño de buffer que permita almacenar 14 caracteres por línea. El número de líneas lo podemos conocer mediante una orden "STAT". Podemos considerar que 14 caracteres por línea es más que suficiente, considerando 4 para el número de mensaje, un espacio separador, 7 para el tamaño del mensaje y dos para el CRLF final.

En el caso de los mensajes, si la información del servidor no es fiable, no hay forma de predecir el tamaño. Por lo tanto optaremos por redimensionar el buffer cada vez que se nos quede pequeño.

Para averiguar cuando se nos acaba el buffer usaremos un bucle en el que primero intentaremos leer tantos caracteres como quepan en él, y después averiguaremos si quedan caracteres pendientes de leer, usando la función BIO_ctrl_pending. Si quedan caracteres por leer, agrandaremos el buffer en el tamaño suficiente para que quepan los caracteres pendientes.

La condición de salida será que en la última lectura, los últimos caracteres sean un punto seguido de CRLF:

    do {
        leido = BIO_read(cBIO, &buffer[nbytes], tambuffer-nbytes);
        nbytes += leido;
        if(BIO_ctrl_pending(cBIO) > 0) {
            // Reasignar buffer.
            Reubicar(tambuffer+BIO_ctrl_pending(cBIO)+1);
            continue;
        }
    } while(buffer[nbytes-3] != '.' || buffer[nbytes-2] != '\r' || buffer[nbytes-1] != '\n');

Procesar mensajes

Cada mensaje se puede dividir en dos partes, las cabeceras y el cuerpo. La separación es una simple línea en blanco, es decir, una línea con un CRLF.

La cabecera contiene información sobre el remitente y el destinatario del mensaje, los servidores por los que ha pasado, fecha de creación, asunto, etc. Los datos no son fijos, ni en el orden ni en la cantidad ni en tipo. Puede haber más o menos, algunos pueden no estar presentes siempre. Los más habituales, que suelen aparecer casi siempre son:

  • Delivered-To: dirección de correo de entrega.
  • Received: que puede y suele aparecer varias veces. Permite conocer el camino que ha seguido el mensaje desde su origen hasta su destino. Cada entrada contiene información sobre el servidor, la hora a la que se procesó el mensaje.
  • Return-Path: dirección de respuesta por defecto, en caso de que el mensaje no pudiera ser enviado. Es la dirección de "rebote". Los servidores pueden procesar esos rebotes para saber si un usuario no ha recibido el mensaje, o si la dirección no existe, o si tiene algún error.
  • From: dirección de correo del remitente, a menudo contiene también un nombre, en el formato nombre <direccion de correo>.
  • Message-ID: un identificador único del mensaje. Estos identificadores se usan para identificar el mensaje, y para establecer jerarquías de mensajes, para determinar qué mensajes responden a cuales.
  • To: dirección del destinatario original del mensaje. No tiene por qué coincidir con el valor de Delivered-To, por ejemplo, si el destinatario es una lista de correo, o está dirigido a varios destinatarios.
  • Date: fecha de creación del mensaje.
  • Importance: prioridad del mensaje, generalmente definida por el remitente.
  • Precedence: indica el tipo de mensaje, puede ser bulk (basura), junk (masivo), list (lista) o un tipo definido por alguna aplicación. Está previsto para que determinados clientes de correo respondan de diferente modo, dependiendo del tipo de mensaje.
  • Reply-To: dirección de correo que se usará para responder al mensaje.
  • Sender: dirección de correo del que ha enviado el mensaje. Puede no coincidir con el campo From, por ejemplo, si se trata de una lista de correo, el que ha escrito el mensaje lo envía a la lista, pero es la lista la que distribuye los mensajes al resto de los miembros, por eso aparecerá la dirección de la lista como "Sender".
  • In-Reply-To: contiene el identificador del mensaje del que el actual es respuesta.
  • References: contiene una lista de identificadores de mensaje que establece una cadena de respuestas.
  • MIME-Version: versión de la codificación MIME usada para codificar el mensaje, normalmente 1.0.
  • Subject: texto del asunto del mensaje.
  • Errors-To: dirección de correo para notificar errores.
  • Content-Type: tipo del contenido del mensaje, dependiendo del formato: texto, html o ambos, de si tiene adjuntos, etc. Este campo es más complicado, y lo veremos en próximas entradas del blog.

Como los nombres pueden estar codificados en diferentes formatos, a menudo en los textos en "From", "Subject", etc. aparece también la información del conjunto de caracteres en el que está codificada la cadena. Por ejemplo: =?iso-8859-1?x?texto?=

Aparecen tres campos, el primero entre =? y ? es el nombre del conjunto de caracteres. El segundo, entre ? y ?, el número de caracteres que contiene la cadena codificada. El tercero, entre ? y ?= la propia cadena.

En próximas entradas del blog veremos más sobre cómo decodificar el formato MIME de los mensajes. De momento nos conformaremos con procesar las cabeceras.

Aplicaciones

Las aplicaciones de leer mensajes desde nuestro programas son muchas. Por ejemplo, podemos crear un programa que nos avise cuando tenemos correo nuevo, o crear un filtro que borre del servidor determinados mensajes. Podríamos crear nuestro propio programa de correo electrónico, o algo más simple, como un programa que sea capaz de procesar órdenes recibidas por correo. Programas para crear listas de correo, etc.

En esta entrada voy a añadir dos programas de ejemplo.

Leer mensajes en consola

El primero se limita a leer mensajes desde un buzón y mostrar una lista de asuntos, remitentes y fechas. Los detalles de la cuenta están codificados en el programa fuente, de modo que habrá que compilarlo para cada caso. De todos modos, es relativamente simple leer esos valores desde una base de datos o desde un fichero de texto. Por supuesto, un programa seguro requeriría encriptar las contraseñas, pero eso está fuera del objetivo de esta entrada.

Notificación de correo nuevo

Aprovechando una entrada anterior de este blog, insertar iconos en el área de notificación, haremos un programa que verifique una dirección de correo cada cinco minutos, y muestre un icono en el área de notificación cuando haya correo nuevo.

Nota:

Para activar el protocolo POP3 en Gmail, hay que ir al menú de configuración y buscar la pestaña "Reenvío y correo POP/IMAP". El punto uno permite habilitar POP3 para los nuevos mensajes que se reciban a partir de ahora, o para todos los mensajes almacenados en la cuenta.

Además, desde que escribí esta entrada, Gmail a mejorado la seguridad y son necesarias nuevas acciones para que se pueda iniciar sesión desde aplicaciones externas. Concretamente, hay que crear una contraseña de aplicación para cada aplicación que requiera iniciar una sesión, y previamente hay que activar también la verificación en dos pasos.