Programação Por Equipe DevBras

Capa do artigo: mail() do PHP retornou true e o e-mail não chegou: o que esse true significa

mail() do PHP retornou true e o e-mail não chegou: o que esse true significa

Entenda o que o retorno de mail() garante, rastreie a mensagem no log do servidor e troque o "e-mail enviado" por uma fila com status real, tentativas e mensagem honesta na tela.

O portal do aluno da escola de idiomas tem um botão "esqueci minha senha". O aluno digita o e-mail, a tela responde "E-mail enviado com sucesso" e nada chega. Nem na caixa de entrada, nem no spam. O suporte abre o código e encontra if (mail(...)) { echo 'E-mail enviado com sucesso'; }. O mail() retornou true. A mensagem estava parada havia dois dias na fila do servidor, recusada pelo provedor do aluno.

A resposta curta: true no mail() significa só que o programa de envio local aceitou a mensagem. Não diz que ela saiu do servidor, nem que foi aceita pelo destino, muito menos que chegou à caixa de entrada. O manual do PHP avisa isso com todas as letras. Trate o envio como uma etapa com status, grave antes de tentar, mande por SMTP autenticado, registre o identificador da mensagem e escreva na tela só o que você sabe.

O caminho que a mensagem percorre depois do true

No Linux, mail() não fala com a internet. Ele chama o programa configurado em sendmail_path no php.ini, normalmente o sendmail de um Postfix ou Exim instalado na própria máquina, e entrega o texto para ele. Assim que esse programa aceita, a função volta com true. No Windows, ela conecta ao servidor SMTP definido em SMTP e smtp_port no php.ini, sem suporte a autenticação.

Depois disso, a mensagem ainda passa por quatro etapas que o PHP nunca vê:

  1. O servidor local tenta entregar ao servidor do destinatário. Se não conseguir, a mensagem fica na fila e ele tenta de novo por horas ou dias.
  2. O servidor de destino confere quem está mandando: o IP está autorizado no SPF do domínio do remetente? A mensagem tem assinatura DKIM válida? O domínio publica uma política DMARC?
  3. Se aceitar, o destino decide entre caixa de entrada, spam ou descarte silencioso, olhando reputação do IP, do domínio e o conteúdo.
  4. Se recusar, uma mensagem de devolução volta para o endereço de retorno, que muitas vezes é www-data@localhost e ninguém lê.

Num VPS recém-criado, quase tudo conspira contra: IP sem reputação, sem DNS reverso, domínio sem SPF nem DKIM, remetente www-data. Gmail e Yahoo passaram a exigir autenticação dos remetentes em 2024, com regras mais duras para quem manda muito. Por que manter um servidor de envio próprio dá tanto trabalho está em servidor de e-mail próprio.

Como descobrir onde a mensagem parou

Antes de mexer no código, descubra o que aconteceu com a mensagem que não chegou. Se o servidor usa Postfix:

# mensagens presas na fila local
mailq

# o que aconteceu com as entregas (Debian/Ubuntu com rsyslog)
grep 'aluno@exemplo.com' /var/log/mail.log

# em sistemas só com journald
journalctl --since today | grep postfix

No log, cada tentativa termina com um status=. sent com código 250 significa que o servidor de destino aceitou; daí em diante, spam ou caixa de entrada é decisão dele. deferred significa recusa temporária e nova tentativa depois. bounced é recusa definitiva, e o texto entre parênteses costuma dizer o motivo, como falha de SPF ou de autenticação. Na escola de idiomas, o log mostrava deferred com a explicação do provedor: remetente não autenticado.

Para testar o lado do domínio, sem depender do código, siga testar a entrega do e-mail.

Como trocar o true por um status de verdade

A correção na escola teve cinco partes.

  1. Grave antes de enviar. O pedido de redefinição vira uma linha numa tabela emails_saida com status pendente. Se o envio falhar, nada se perde e dá para tentar de novo.
  2. Envie por SMTP autenticado de um serviço de envio transacional, com remetente do seu domínio e SPF e DKIM configurados conforme as instruções do serviço. O formulário de contato segue a mesma receita em formulário de contato que chega.
  3. Registre o identificador da mensagem. O cabeçalho Message-ID permite achar aquela mensagem exata no log do serviço quando o aluno reclamar.
  4. Tente de novo com intervalo crescente e desista depois de algumas tentativas, marcando falhou e guardando o último erro.
  5. Escreva na tela só o que é verdade. "Se houver uma conta com esse e-mail, enviamos um link. Pode levar alguns minutos; confira também o spam." Isso também evita revelar quais e-mails têm conta.

O processo que envia, rodado pelo cron a cada minuto, com PHPMailer:

<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

require __DIR__ . '/../vendor/autoload.php';
$pdo = require __DIR__ . '/../config/banco.php';

$pendentes = $pdo->query(
    "SELECT id, destinatario, assunto, corpo
       FROM emails_saida
      WHERE status = 'pendente' AND proxima_tentativa <= NOW()
      ORDER BY id
      LIMIT 20"
)->fetchAll(PDO::FETCH_ASSOC);

foreach ($pendentes as $email) {
    $mail = new PHPMailer(true);
    try {
        $mail->isSMTP();
        $mail->Host       = getenv('SMTP_HOST');
        $mail->SMTPAuth   = true;
        $mail->Username   = getenv('SMTP_USER');
        $mail->Password   = getenv('SMTP_PASS');
        $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
        $mail->Port       = 587;
        $mail->CharSet    = 'UTF-8';

        $mail->setFrom('nao-responda@falabem.example', 'Escola Fala Bem');
        $mail->addAddress($email['destinatario']);
        $mail->Subject = $email['assunto'];
        $mail->Body    = $email['corpo'];
        $mail->send();

        $pdo->prepare(
            "UPDATE emails_saida
                SET status = 'aceito', message_id = :mid, enviado_em = NOW()
              WHERE id = :id"
        )->execute(['mid' => $mail->getLastMessageID(), 'id' => $email['id']]);
    } catch (Exception $e) {
        $pdo->prepare(
            "UPDATE emails_saida
                SET status = IF(tentativas + 1 >= 5, 'falhou', 'pendente'),
                    proxima_tentativa = NOW() + INTERVAL (tentativas + 1) * 5 MINUTE,
                    ultimo_erro = :erro,
                    tentativas = tentativas + 1
              WHERE id = :id"
        )->execute(['erro' => $mail->ErrorInfo, 'id' => $email['id']]);
    }
}

A ordem das atribuições no último UPDATE importa: o MySQL avalia um UPDATE de tabela única da esquerda para a direita, então tentativas só é incrementado no fim, depois de ser usado no cálculo. E o status se chama aceito, não entregue, porque é isso que o SMTP confirmou.

No cron, flock impede que duas execuções peguem as mesmas mensagens se uma demorar mais de um minuto:

* * * * * flock -n /tmp/emails-saida.lock php /var/www/portal/bin/enviar-emails.php

Para saber das devoluções que chegam depois do aceito, a maioria dos serviços de envio oferece webhook de bounce e de reclamação de spam. Ligue esse webhook a um status devolvido na mesma tabela e pare de mandar para endereços que devolvem sempre.

Cabeçalho montado com dado do visitante

O código antigo da escola fazia $headers = "From: " . $_POST['email'] no formulário de contato. Uma quebra de linha no campo permite acrescentar cabeçalhos como Bcc e usar o seu servidor para mandar spam. Nunca coloque texto do visitante em cabeçalho sem validar: o e-mail dele vai no Reply-To, depois de passar por filter_var com FILTER_VALIDATE_EMAIL, e o From é sempre um endereço seu.

Erros comuns

  • Mostrar "e-mail enviado" porque mail() retornou true. A pessoa espera um link que está preso na fila ou foi recusado.
  • Remetente www-data@localhost ou de um domínio sem SPF e DKIM. Os grandes provedores recusam ou jogam no spam.
  • Não gravar a mensagem antes de enviar. Se o envio falha, o pedido de redefinição some e ninguém fica sabendo.
  • Colocar o campo do visitante no cabeçalho From. Além de falhar no SPF, abre a porta para injeção de cabeçalho.
  • Enviar dentro da requisição do usuário. Se o SMTP demora, a tela trava e a pessoa clica de novo, gerando dois links.
  • Nunca olhar mailq nem o log. Mensagens se acumulam por dias e o primeiro aviso é o aluno no telefone.

Checklist

  • Nenhuma tela promete entrega com base no retorno de mail() ou de send().
  • Toda mensagem é gravada com status antes do envio.
  • O envio usa SMTP autenticado com TLS e remetente do seu domínio.
  • O domínio do remetente tem SPF, DKIM e DMARC publicados.
  • O Message-ID de cada envio fica registrado.
  • Falhas são tentadas de novo com intervalo e viram falhou com o erro guardado.
  • Nenhum dado do visitante entra em cabeçalho sem validação.

Rode mailq no servidor do seu sistema agora: a fila está vazia ou há mensagens esperando há dias por alguém que acha que elas já chegaram?

Avalie este artigo

Referência: http://www.linhadecodigo.com.br/artigo/3619/enviando-email-com-php.aspx