Construindo um cliente de e-mail IMAP com PHP
Publicados: 2022-03-11Os desenvolvedores às vezes se deparam com tarefas que exigem acesso a caixas de correio de email. Na maioria dos casos, isso é feito usando o Internet Message Access Protocol, ou IMAP. Como um desenvolvedor PHP, eu me voltei para a biblioteca IMAP integrada do PHP, mas essa biblioteca é cheia de bugs e impossível de depurar ou modificar. Também não é possível personalizar os comandos IMAP para fazer uso total das capacidades do protocolo.
Então, hoje, vamos criar um cliente de e-mail IMAP funcional desde o início usando PHP. Veremos também como usar os comandos especiais do Gmail.
Implementaremos o IMAP em uma classe personalizada, imap_driver
. Explicarei cada passo durante a construção da classe. Você pode baixar todo o imap_driver.php
no final do artigo.
Estabelecendo uma conexão
O IMAP é um protocolo baseado em conexão e normalmente opera sobre TCP/IP com segurança SSL, portanto, antes de podermos fazer qualquer chamada IMAP, devemos abrir a conexão.
Precisamos saber a URL e o número da porta do servidor IMAP ao qual queremos nos conectar. Essas informações geralmente são divulgadas no site ou na documentação do serviço. Por exemplo, para o Gmail, o URL é ssl://imap.gmail.com
na porta 993.
Como queremos saber se a inicialização foi bem-sucedida, deixaremos nosso construtor de classe vazio e todas as conexões serão feitas em um método init()
personalizado, que retornará false
se a conexão não puder ser estabelecida:
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); } ... }
No código acima, defini um tempo limite de 15 segundos, tanto para fsockopen()
estabelecer a conexão quanto para o próprio fluxo de dados responder às solicitações assim que estiver aberto. É importante ter um tempo limite para cada chamada para a rede porque, muitas vezes, o servidor não responde e devemos ser capazes de lidar com esse congelamento.
Eu também pego a primeira linha do fluxo e a ignoro. Normalmente, isso é apenas uma mensagem de saudação do servidor ou uma confirmação de que ele está conectado. Verifique a documentação do seu serviço de correio específico para certificar-se de que este é o caso.
Agora queremos executar o código acima para ver se o init()
foi bem-sucedido:
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; }
Sintaxe IMAP Básica
Agora que temos um soquete ativo aberto para nosso servidor IMAP, podemos começar a enviar comandos IMAP. Vamos dar uma olhada na sintaxe IMAP.
A documentação formal pode ser encontrada na Internet Engineering Task Force (IETF) RFC3501. As interações IMAP geralmente consistem no cliente enviando comandos e o servidor respondendo com uma indicação de sucesso, juntamente com quaisquer dados que possam ter sido solicitados.
A sintaxe básica para comandos é:
line_number command arg1 arg2 ...
O número da linha, ou “tag”, é um identificador exclusivo para o comando, que o servidor usa para indicar a qual comando está respondendo caso esteja processando vários comandos ao mesmo tempo.
Aqui está um exemplo, mostrando o comando LOGIN
:
00000001 LOGIN [email protected] password
A resposta do servidor pode começar com uma resposta de dados “não marcada”. Por exemplo, o Gmail responde a um login bem-sucedido com uma resposta sem etiqueta contendo informações sobre os recursos e opções do servidor, e um comando para buscar uma mensagem de e-mail receberá uma resposta sem etiqueta contendo o corpo da mensagem. Em ambos os casos, uma resposta deve sempre terminar com uma linha de resposta de conclusão de comando “marcada”, identificando o número da linha do comando ao qual a resposta se aplica, um indicador de status de conclusão e metadados adicionais sobre o comando, se houver:
line_number status metadata1 metadata2 ...
Veja como o Gmail responde ao comando LOGIN
:
- Sucesso:
* 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)
- Falha:
00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)
O status pode ser OK
, indicando sucesso, NO
, indicando falha ou BAD
, indicando um comando inválido ou sintaxe incorreta.
Implementando comandos básicos:
Vamos fazer uma função para enviar um comando para o servidor IMAP e recuperar a resposta e a linha 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); } ... }
O comando LOGIN
Agora podemos escrever funções para comandos específicos que chamam nossa função command()
sob o capô. Vamos escrever uma função para o 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; } } ... }
Agora podemos testar assim. (Observe que você deve ter uma conta de e-mail ativa para testar.)
... // test for login() if ($imap_driver->login('[email protected]', 'password') === false) { echo "login() failed: " . $imap_driver->error . "\n"; exit; }
Observe que o Gmail é muito rigoroso em relação à segurança por padrão: ele não nos permitirá acessar uma conta de e-mail com IMAP se tivermos configurações padrão e tentarmos acessá-la de um país diferente do país do perfil da conta. Mas é bastante fácil de consertar; basta definir configurações menos seguras em sua conta do Gmail, conforme descrito aqui.

O comando SELECT
Agora vamos ver como selecionar uma pasta IMAP para fazer algo útil com nosso e-mail. A sintaxe é semelhante à de LOGIN
, graças ao nosso método command()
. Em vez disso, usamos o comando SELECT
e especificamos a pasta.
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 testar, vamos tentar selecionar a INBOX:
... // test for select_folder() if ($imap_driver->select_folder("INBOX") === false) { echo "select_folder() failed: " . $imap_driver->error . "\n"; return false; }
Implementando comandos avançados
Vejamos como implementar alguns dos comandos mais avançados do IMAP.
O comando SEARCH
Uma rotina comum na análise de email é pesquisar emails em um determinado intervalo de datas ou pesquisar emails sinalizados e assim por diante. Os critérios de pesquisa devem ser passados para o comando SEARCH
como argumento, com espaço como separador. Por exemplo, se queremos receber todos os emails desde 20 de novembro de 2015, devemos passar o seguinte comando:
00000005 SEARCH SINCE 20-Nov-2015
E a resposta será algo assim:
* SEARCH 881 882 00000005 OK SEARCH completed
A documentação detalhada de possíveis termos de pesquisa pode ser encontrada aqui. A saída de um comando SEARCH
é uma lista de UIDs de e-mails, separados por espaços em branco. Um UID é um identificador exclusivo de um email na conta do usuário, em ordem cronológica, onde 1 é o email mais antigo. Para implementar o comando SEARCH
devemos simplesmente retornar os UIDs 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 testar este comando, receberemos e-mails dos últimos três dias:
... // 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; }
O comando FETCH
com BODY.PEEK
Outra tarefa comum é obter cabeçalhos de e-mail sem marcar um e-mail como SEEN
. No manual do IMAP, o comando para recuperar todo ou parte de um email é FETCH
. O primeiro argumento indica em qual parte estamos interessados e, normalmente, BODY
é passado, o que retornará a mensagem inteira junto com seus cabeçalhos e a marcará como SEEN
. O argumento alternativo BODY.PEEK
fará a mesma coisa, sem marcar a mensagem como SEEN
.
A sintaxe IMAP exige que nossa solicitação também especifique, entre colchetes, a seção do email que queremos buscar, que neste exemplo é [HEADER]
. Como resultado, nosso comando ficará assim:
00000006 FETCH 2 BODY.PEEK[HEADER]
E esperamos uma resposta parecida com esta:
* 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 uma função para buscar cabeçalhos, precisamos ser capazes de retornar a resposta em uma estrutura de hash (pares chave/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; } } ... }
E para testar este código basta especificar o UID da mensagem que nos 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; }
Extensões IMAP do Gmail
O Gmail fornece uma lista de comandos especiais que podem tornar nossa vida muito mais fácil. A lista de comandos de extensão IMAP do Gmail está disponível aqui. Vamos rever um comando que, na minha opinião, é o mais importante: X-GM-RAW
. Ele nos permite usar a sintaxe de pesquisa do Gmail com IMAP. Por exemplo, podemos pesquisar e-mails nas categorias Primário, Social, Promoções, Atualizações ou Fóruns.
Funcionalmente, X-GM-RAW
é uma extensão do comando SEARCH
, então podemos reutilizar o código que temos acima para o comando SEARCH
. Tudo o que precisamos fazer é adicionar a palavra-chave X-GM-RAW
e os critérios:
... // 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; }
O código acima retornará todos os UIDs listados na categoria “Primário”.
Observação: desde dezembro de 2015, o Gmail costuma confundir a categoria "Principal" com a categoria "Atualizações" em algumas contas. Este é um bug do Gmail que ainda não foi corrigido.
Conclusão
No geral, a abordagem de soquete personalizado oferece mais liberdade ao desenvolvedor. Possibilita implementar todos os comandos em IMAP RFC3501. Também lhe dará melhor controle sobre seu código, já que você não precisa se perguntar o que está acontecendo “nos bastidores”.
A classe imap_driver
completa que implementamos neste artigo pode ser encontrada aqui. Ele pode ser usado como está e levará apenas alguns minutos para um desenvolvedor escrever uma nova função ou solicitação para seu servidor IMAP. Também incluí um recurso de depuração na classe para uma saída detalhada.