
Não uses mail(): sem servidor de correio local devolve false, e as suas mensagens já não passam os filtros. Envia por SMTP autenticado com o PHPMailer ou o Symfony Mailer, e publica SPF, DKIM e DMARC no domínio. O From tem de pertencer ao teu domínio, o endereço do visitante vai para Reply-To.
Enviar um email a partir do PHP é fácil. Fazê-lo chegar à caixa de entrada é bem mais difícil. A função mail(), aquela que quase todos os tutoriais mostram primeiro, falha nos dois planos em 2026: não faz nada sem um servidor de correio local e, quando funciona, as suas mensagens acabam no spam. Este artigo mostra o que resulta mesmo, e porquê.
Porque é que mail() já não chega
A função mail() não fala SMTP. Passa o trabalho a um binário local, sendmail, indicado pela diretiva sendmail_path. Se não houver servidor de correio na máquina, não acontece nada.
<?php
echo ini_get('sendmail_path'), "\n"; // /usr/sbin/sendmail -t -i
$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);Num contentor PHP 8.5 normal, sem MTA instalado:
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)É a situação por omissão de qualquer contentor Docker, da maior parte das imagens de desenvolvimento e de uma boa parte dos alojamentos. O código não assinala nada de explícito: devolve false, e o valor de retorno é quase sempre ignorado.
Mesmo quando existe um MTA, true não quer dizer «mensagem entregue». Quer dizer «a mensagem foi aceite pelo programa local». O que acontece a seguir, recusa do servidor remoto, quarentena, rejeição silenciosa, é invisível a partir do PHP.
O verdadeiro problema: autenticar o domínio
Desde fevereiro de 2024, o Gmail e o Yahoo exigem autenticação completa aos remetentes de volume. A Microsoft seguiu em 2025. Uma mensagem que não esteja assinada nem alinhada deixou de chegar, ou chega ao spam. Combinam-se três mecanismos.
SPF
Um registo DNS de tipo TXT no teu domínio, que lista os servidores autorizados a enviar em nome dele.
exemple.com. IN TXT "v=spf1 include:_spf.mon-fournisseur.com -all"O -all final significa «qualquer outro servidor é ilegítimo». Um ~all é mais permissivo e chega durante a instalação, mas o alvo é o -all.
DKIM
Uma assinatura criptográfica acrescentada ao cabeçalho da mensagem. O destinatário vai buscar a chave pública ao DNS e verifica que a mensagem não foi alterada e vem mesmo do domínio anunciado.
# Gerar o par de chaves
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemA chave pública publica-se em <selecteur>._domainkey.exemple.com:
gk2026._domainkey.exemple.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."A chave privada fica no servidor, fora do repositório Git, legível apenas pelo utilizador que executa o PHP.
DMARC
A política que diz ao destinatário o que fazer quando o SPF e o DKIM falham, e para onde enviar os relatórios.
_dmarc.exemple.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"Começa-se sempre por p=none, que não rejeita nada mas faz chegar relatórios. Leem-se esses relatórios durante algumas semanas, corrige-se o que aparece, e só depois se passa a p=quarantine e a p=reject. Ir diretamente para p=reject bloqueia as tuas próprias mensagens.
O ponto que estes três registos não resolvem sozinhos chama-se alinhamento: o endereço do From: visível tem de pertencer ao mesmo domínio que o validado pelo SPF e assinado pelo DKIM. Um formulário de contacto que ponha o endereço do visitante em From: parte esse alinhamento e falha o DMARC do domínio dele. A forma correta é enviar a partir do teu domínio e pôr o visitante em Reply-To:.
Enviar por SMTP autenticado com o PHPMailer
O PHPMailer trata do SMTP, da codificação, dos anexos e da assinatura DKIM. É a biblioteca mais divulgada das duas, e é mantida.
composer require phpmailer/phpmailer<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
$mail = new PHPMailer(true); // true: os erros levantam uma exceção
try {
$mail->isSMTP();
$mail->Host = getenv('SMTP_HOST') ?: throw new RuntimeException('SMTP_HOST manquant');
$mail->Port = (int) (getenv('SMTP_PORT') ?: 587);
$mail->SMTPAuth = true;
$mail->Username = getenv('SMTP_USER') ?: '';
$mail->Password = getenv('SMTP_PASS') ?: '';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // 587
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->Timeout = 15;
// Assinatura DKIM
$mail->DKIM_domain = 'exemple.com';
$mail->DKIM_private = '/chemin/hors/depot/dkim-prive.pem';
$mail->DKIM_selector = 'gk2026';
$mail->DKIM_identity = 'contact@exemple.com';
$mail->setFrom('contact@exemple.com', 'Exemple');
$mail->addAddress('lecteur@example.com', 'Lecteur');
$mail->addReplyTo('contact@exemple.com');
$mail->Subject = 'Confirmation de votre inscription';
$mail->isHTML(true);
$mail->Body = '<h1>Bienvenue</h1><p>Votre compte est actif.</p>';
$mail->AltBody = "Bienvenue\nVotre compte est actif.";
$mail->send();
} catch (MailException $e) {
error_log('Envoi impossible : ' . $mail->ErrorInfo);
throw new RuntimeException('Envoi impossible', previous: $e);
}A versão em texto simples (AltBody) não é facultativa. Uma mensagem só em HTML é um sinal negativo para os filtros, e há clientes que mostram apenas a parte de texto.
No nosso ambiente de teste, a mensagem parte em 148 ms e chega com o cabeçalho seguinte:
DKIM-Signature: v=1; d=gekkode.com; s=gk2026;
a=rsa-sha256; q=dns/txt; t=1788378681; c=relaxed/simple;
h=Date:To:From:Reply-To:Subject:Message-ID:X-Mailer:MIME-Version:Content-Type;
i=contact@gekkode.com;
bh=amLL8hgBhL3CiuV3ieZS24St8xKkkSLjcdpx2cHdvoI=;O mesmo com o Symfony Mailer
Se o projeto já usa componentes Symfony, o Symfony Mailer integra-se melhor e trata da fila de espera.
composer require symfony/mailer<?php
declare(strict_types=1);
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
$dsn = getenv('MAILER_DSN'); // smtp://utilisateur:motdepasse@hote:587
$transport = Transport::fromDsn($dsn);
$mailer = new Mailer($transport);
$email = (new Email())
->from('contact@exemple.com')
->to('lecteur@example.com')
->replyTo('contact@exemple.com')
->subject('Confirmation de votre inscription')
->text("Bienvenue\nVotre compte est actif.")
->html('<h1>Bienvenue</h1><p>Votre compte est actif.</p>');
$mailer->send($email);Testar sem poluir caixas de correio reais
Disparar ensaios contra endereços reais estraga a reputação do domínio. Um servidor SMTP de teste interceta tudo e oferece uma interface web. O Mailpit faz exatamente isso, num contentor.
services:
mail:
image: axllent/mailpit:latest
ports:
- "8025:8025" # interface web
- "1025:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"A aplicação passa então a apontar para smtp://mail:1025, e todas as mensagens se abrem em http://localhost:8025, com a fonte completa. É a única forma de verificar que um cabeçalho DKIM está mesmo lá antes de passar a produção.
O formulário de contacto, caso particular
Três erros aparecem sistematicamente, e todos têm consequências.
// A não fazer
$mail->setFrom($_POST['email']); // parte o SPF e o DKIM
$mail->Subject = $_POST['sujet']; // injeção de cabeçalhos
$mail->addAddress($_POST['destinataire']); // relé aberto
// Correto
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
throw new InvalidArgumentException('Adresse invalide');
}
$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // sempre o teu domínio
$mail->addReplyTo($expediteur); // a resposta vai para o visitante
$mail->addAddress('contact@exemple.com'); // destinatário fixo
$mail->Subject = 'Message du formulaire de contact'; // assunto fixo
$mail->Body = nl2br(htmlspecialchars(
(string) ($_POST['message'] ?? ''),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
));A injeção de cabeçalhos merece uma palavra à parte. Uma mudança de linha metida num campo passado a mail() permitia acrescentar destinatários escondidos e transformar o formulário num relé de spam. Enviámos o assunto "Sujet normalrnBcc: victime@example.com" pelas duas bibliotecas: nenhuma levanta exceção, e nenhuma cria o cabeçalho Bcc. Neutralizam a quebra de linha na codificação.
PHPMailer 7.1.1 -> Subject: Sujet normalBcc: victime@example.com
Symfony Mailer -> Subject: Sujet =?utf-8?Q?normal?=
Dans les deux cas : aucun en-tête Bcc dans le message livré.O resultado é seguro, mas nada o assinala: o assunto chega deformado sem um único aviso. Validar os campos a montante continua a ser necessário, e montar os cabeçalhos à mão continua a ser de evitar.
Quando passar por um serviço de envio
Um SMTP montado por ti num VPS funciona, até ao dia em que o endereço IP vai parar a uma lista negra. Os fornecedores de envio transacional, Brevo, Postmark, Amazon SES, Mailgun, tratam da reputação dos endereços IP, dos ciclos de retorno e dos relatórios DMARC. Usam-se em SMTP clássico, e aí o código acima não muda, ou por API.
É o terreno do Email Impact, o módulo PrestaShop que editamos para os emails transacionais de uma loja: modelos, entregabilidade e acompanhamento dos envios. Os mecanismos descritos aqui, SMTP autenticado, alinhamento do From:, assinatura DKIM, são exatamente aqueles em que ele assenta, e as mesmas definições de DNS aplicam-se seja qual for a ferramenta de envio.
Enviar muitas mensagens
Um ciclo que envia mil mensagens dentro de um pedido HTTP acaba em tempo esgotado. É preciso uma fila: o pedido regista as mensagens a enviar, e uma tarefa agendada trata delas por lotes.
// Um envio por lotes, chamado a partir de uma tarefa agendada
$parLot = 30;
$aTraiter = $file->prochains($parLot);
foreach ($aTraiter as $message) {
try {
$mailer->send($message->email());
$file->marquerEnvoye($message->id);
} catch (Throwable $e) {
$file->marquerEchec($message->id, $e->getMessage());
}
usleep(200_000); // respeita os limites de débito do fornecedor
}Uma falha numa mensagem nunca deve interromper o lote. E cada mensagem de uma lista de difusão tem de levar um cabeçalho List-Unsubscribe com uma ligação de cancelamento que funcione: é uma exigência dos grandes fornecedores desde 2024, não uma cortesia.
Em resumo
mail(): para um script local de que ninguém depende, e mais nada.- SMTP autenticado com o PHPMailer ou o Symfony Mailer, com SPF, DKIM e DMARC publicados: o mínimo para ser entregue.
- Um servidor SMTP de teste em desenvolvimento, nunca endereços reais.
- Um serviço de envio transacional assim que o volume passar de algumas mensagens por dia.
Para anexar um ficheiro enviado por um visitante, valida-o primeiro como se descreve em carregar um ficheiro em PHP. Para comandar um serviço de envio pela API em vez de o usar em SMTP, vê fazer um pedido cURL em PHP. As duas bibliotecas citadas aqui instalam-se com o Composer.
Vê também validar um endereço de email e o hub Desenvolvimento web.
Erros frequentes
false com Sendmail exited with non-zero exit code 127. É o caso por omissão de qualquer contentor Docker e de uma boa parte dos alojamentos.Reply-To.p=none, leem-se os relatórios, e só depois se aperta.AltBody."SujetrnBcc: victime@example.com" não cria nenhum cabeçalho Bcc com o PHPMailer nem com o Symfony Mailer, mas nenhum dos dois levanta exceção: o assunto chega deformado sem aviso.

