Enviar um email com PHP: SMTP, DKIM e entregabilidade

Enviar mensagens de correio eletrónico é uma tarefa muito comum numa aplicação web: um email de boas-vindas quando um utilizador cria conta no teu site, newsletters para os utilizadores registados, o simples formulário de contacto do site, e por aí fora. Podes usar a função integrada do PHP

Enviar um email com PHP: SMTP, DKIM e entregabilidade
Resposta rápida

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
<?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:

code
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.

code
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.

bash
# Gerar o par de chaves
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

A chave pública publica-se em <selecteur>._domainkey.exemple.com:

code
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.

code
_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.

bash
composer require phpmailer/phpmailer
src/envoi.php
<?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:

code
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.

bash
composer require symfony/mailer
php
<?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.

docker-compose.yml
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.

php
// 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.

code
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.

php
// 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

mail() sem servidor de correio Devolve 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.
O retorno true de mail() não significa «entregue» Significa «aceite pelo programa local». A recusa do servidor remoto é invisível a partir do PHP.
From com o endereço do visitante Parte o alinhamento SPF e DKIM, e faz falhar o DMARC do domínio do visitante. O remetente é o teu domínio, o visitante vai para Reply-To.
DMARC publicado logo em p=reject Bloqueia as tuas próprias mensagens. Começa-se por p=none, leem-se os relatórios, e só depois se aperta.
Mensagem apenas em HTML Sinal negativo para os filtros. Fornece sempre uma versão de texto em AltBody.
Injeção de cabeçalho O assunto "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.
Envio em massa dentro de um pedido HTTP Acaba em tempo esgotado. É preciso uma fila tratada por lotes a partir de uma tarefa agendada.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.