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ê:
- 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.
- 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?
- 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.
- Se recusar, uma mensagem de devolução volta para o endereço de retorno, que muitas vezes é
www-data@localhoste 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.
- Grave antes de enviar. O pedido de redefinição vira uma linha numa tabela
emails_saidacom statuspendente. Se o envio falhar, nada se perde e dá para tentar de novo. - 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.
- Registre o identificador da mensagem. O cabeçalho
Message-IDpermite achar aquela mensagem exata no log do serviço quando o aluno reclamar. - Tente de novo com intervalo crescente e desista depois de algumas tentativas, marcando
falhoue guardando o último erro. - 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()retornoutrue. A pessoa espera um link que está preso na fila ou foi recusado. - Remetente
www-data@localhostou 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
mailqnem 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 desend(). - 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-IDde cada envio fica registrado. - Falhas são tentadas de novo com intervalo e viram
falhoucom 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?
Referência: http://www.linhadecodigo.com.br/artigo/3619/enviando-email-com-php.aspx