Search by

wisedata / mail-php

wisedata

Cliente PHP da API do WiseData Mail: contatos e envio de e-mail.

v1.7.2 2026-11-02 14:37 UTC

This package is auto-updated.

Last update: 2026-11-02 16:06:37 UTC


README

Cliente PHP da API do WiseData Mail. Sincroniza contatos e dispara e-mail transacional — em Laravel, trocando uma linha do .env.

Sem dependência de produção: só ext-curl e ext-json, que já vêm no PHP.

composer require wisedata/mail-php

Uso

use WiseData\Mail\Client;

$mail = new Client('wdm_abc_def');

$mail->contacts()->upsert([
    'external_id' => 'usr_9',          // o id desta pessoa NO SEU sistema
    'email'       => 'ana@exemplo.com',
    'first_name'  => 'Ana',
    'fields'      => ['plano_atual' => 'pro'],
    'lists'       => [3],
]);

upsert cria ou corrige: mandar a mesma pessoa duas vezes deixa a base no mesmo estado. É por isso que ele pode ser chamado de dentro de um job que reprocessa fila depois de uma queda.

external_id é o que evita duplicado

O e-mail muda. Sem um identificador estável, o aviso de "fulano trocou de e-mail" chega sem dizer qual fulano, e nasce um segundo contato — com o antigo ainda recebendo.

Mandando external_id, a troca de endereço corrige o contato que já existe. Quem não tem um identificador próprio pode omitir: aí o e-mail é a chave, como sempre foi.

O resto dos contatos

$mail->contacts()->find('usr_9');                       // por external_id
$mail->contacts()->findByEmail('ana@exemplo.com');      // null se não achar
$mail->contacts()->list(page: 1, perPage: 25);
$mail->contacts()->delete('usr_9');
$mail->contacts()->deleteByEmail('ana@exemplo.com');    // para quem não tem external_id

lists no upsert DEFINE o conjunto: ausente mantém as listas do contato, lists: [] tira de todas. Sem a distinção, corrigir um telefone apagaria a segmentação dele.

Para ACRESCENTAR sem conhecer as outras listas — o caso de quem administra uma lista entre várias —, add_lists no próprio upsert, numa chamada só:

$mail->contacts()->upsert([
    'external_id' => 'usr_9',
    'email' => 'ana@exemplo.com',
    'add_lists' => ['clientes-finances'],
]);

// ou, para um contato que já existe:
$mail->contacts()->addToLists('usr_9', ['clientes-finances']);
$mail->contacts()->removeFromLists('usr_9', ['clientes-finances']);

Com o lists seria preciso ler antes de escrever, e a corrida apagaria o que outro produto vinculou no intervalo. lists e add_lists juntos são recusados.

A lista pela chave

Cada lista tem uma chave (clientes-finances) que nasce do nome e nunca muda. Use-a em vez do id: é a mesma em todo ambiente — o .env de produção e o de desenvolvimento ficam iguais — e diz qual lista é. Ela aparece no painel em Listas e no bloco Integração de cada espaço — e da conta principal. O id continua aceito.

Listas padrão pelo .env

WISEDATA_MAIL_CONTACT_LISTS=clientes-finances,leads

Todo upsert() que não fala de listas passa a mandar add_lists com elas — o usuário que confirmou o e-mail entra na lista do produto sem uma linha a mais no código. A chamada que manda lists ou add_lists decide sozinha.

As listas padrão são do espaço configurado: forSpace('outro') as larga, porque a mesma chave em outro espaço seria outra lista. Fora do Laravel, $mail->withContactLists(['clientes-finances']).

Campos personalizados

A gravação recusa campo que não existe na conta, com o nome dele no erro. Descubra os nomes uma vez, no boot do seu serviço:

$mail->catalog()->fields();   // [['key' => 'plano_atual', 'type' => 'text', ...], ...]
$mail->catalog()->lists();    // [['key' => 'clientes-finances', 'id' => 3, 'name' => 'Clientes'], ...]

Campo personalizado e lista falam por key — nunca pelo nome: a pessoa renomeia a lista na tela sem avisar ninguém, e o nome deixaria de casar em silêncio.

Espaços de envio

Se a sua chave alcança mais de um espaço, diga qual em cada chamada:

$marcaB = $mail->forSpace('marca-b');
$marcaB->contacts()->upsert([...]);

forSpace() devolve outro cliente — o original continua apontando para onde estava. Com vários espaços vinculados e nenhum escolhido, a API recusa em vez de adivinhar: escolher errado gravaria o cliente de uma marca na base de outra, em silêncio.

Espaços marcados na chave Sem espaço na chamada Com espaço
nenhum (chave da conta principal) a conta principal recusa (space_not_allowed)
um esse espaço só ele
vários recusa (space_required) um dos marcados
chave de plataforma a conta principal qualquer espaço da conta, inclusive os criados depois

A chave sem espaço marcado não é chave-mestra. Ela age só na conta principal; para alcançar um espaço, marque-o na chave (Configurações › Integrações). Um espaço criado depois também não entra sozinho. Quem precisa da conta principal e de um espaço usa duas chaves — ou a chave de plataforma.

Seus clientes, cada um no seu espaço

Para o produto que envia em nome dos clientes dele, cada um com o próprio domínio e o próprio remetente — como subcontas. Exige a chave de plataforma: o Dono a emite em Configurações › Integrações, marcando "Chave de plataforma". Ela não tem espaços marcados nem vale para homologação, e é a única que recebe os escopos spaces.*.

// 1. O cliente do seu sistema vira um espaço. Repetir atualiza, não duplica.
$espaco = $mail->spaces()->upsert(externalId: 'cliente_812', name: 'Avcom');

// Guarde o slug que VOLTOU: é o valor do forSpace().
$doCliente = $mail->forSpace($espaco['slug']);

// 2. O domínio dele. Devolve os registros DNS para você mostrar ao cliente.
$dominio = $doCliente->domains()->register('avcom.com.co');
foreach ($dominio['records'] as $r) {
    // $r['type'], $r['name'], $r['value'], $r['priority'], $r['status']
}

// 3. Quando o webhook avisar domain.verified, o remetente:
$doCliente->senders()->upsert('encuestas@avcom.com.co', 'Avcom', label: 'Pesquisas');

// 4. E o envio sai com o nome do cliente.
$doCliente->emails()->send([
    'to' => 'ana@exemplo.com',
    'from' => 'encuestas@avcom.com.co',
    'subject' => '¿Cómo fue su experiencia?',
    'html' => '<p>...</p>',
]);
  • O Mail confere o DNS sozinho e avisa pelo webhook: domain.verified (pronto), domain.failed (desistiu sem ficar pronto) e domain.degraded (estava pronto e deixou de estar). Não consulte find() em laço.
  • DMARC é obrigatório. Sem um registro _dmarc (no próprio domínio, ou no organizacional quando o envio é de um subdomínio) o domínio não fica pronto.
  • domains()->verify() é o botão "já publiquei": confere na hora e retoma a conferência que tinha desistido. Um por minuto por domínio — antes disso, rate_limited com retryAfter().
  • O remetente da API nunca vira o padrão do espaço: informe o from no envio. O domínio exato do endereço precisa estar cadastrado no espaço.
  • spaces() sai sempre pela conta (a API recusa o cabeçalho de espaço nessas rotas), então dá para chamá-lo do mesmo $mail de sempre.
  • spaces()->delete() apaga o espaço com tudo o que é dele. Espaço com envio suspenso não sai (space_suspended).

Enviando e-mail

$mail->emails()->send([
    'to' => 'ana@exemplo.com',
    'to_name' => 'Ana',
    'subject' => 'Seu código de acesso',
    'html' => '<p>Seu código é <strong>123456</strong>.</p>',
    'text' => 'Seu código é 123456.',
], idempotencyKey: 'codigo-usr9-2026-09-24');

A resposta traz id e status. suppressed não é erro: a chamada foi aceita, e a mensagem não sai porque aquela pessoa devolveu, reclamou ou foi suprimida na conta. Nada é cobrado da cota.

Um destinatário por mensagem. Transacional é de uma pessoa — o recibo é dela, o código é dela. Para falar com um grupo existe a campanha.

Anexo pela API crua

$mail->emails()->send([
    'to' => 'ana@exemplo.com',
    'subject' => 'Seu relatório de setembro',
    'html' => '<p>Segue em anexo.</p>',
    'attachments' => [[
        'filename' => 'relatorio-setembro.pdf',
        'content' => base64_encode(file_get_contents($caminho)),
        'content_type' => 'application/pdf',
    ]],
]);

filename e content (em base64) são obrigatórios; content_type é opcional — sem ele o tipo é deduzido da extensão, então informe-o quando o nome não tiver uma.

Para imagem embutida, disposition e content_id, e o HTML referencia por cid::

'attachments' => [[
    'filename' => 'logo.png',
    'content' => base64_encode($png),
    'content_type' => 'image/png',
    'disposition' => 'inline',
    'content_id' => 'logo123',
]],
// no HTML: <img src="cid:logo123">

Dez megabytes somando tudo, dez arquivos. Extensão de executável ou script é recusada com 422 nomeando o arquivo — a lista é do provedor de envio.

Anexo combina com template, ao contrário de subject/html/text: o modelo guarda o texto, a chamada manda o arquivo.

Modelo salvo no Mail

O texto pode viver no painel em vez de no seu código, endereçado por uma chave:

$mail->emails()->send([
    'to' => 'ana@exemplo.com',
    'template' => 'boas-vindas',
    'variables' => ['nome' => 'Ana', 'plano' => 'Crescimento'],
]);

Assim, corrigir uma frase do e-mail é uma edição na tela — não um release do seu produto.

As variáveis são as que o texto do modelo usa, detectadas dele. Nome que o modelo não usa (unknown_variable, e a resposta traz os aceitos) e nome que ele usa e não veio (missing_variable) são recusados com 422 — o e-mail nunca sai com um buraco no lugar do dado.

template não se combina com subject/html/text: a chamada escolhe uma das duas formas.

A chave de idempotência

Mandar a mesma chave de novo devolve o mesmo registro, sem reenviar — é o que permite reprocessar uma fila depois de uma queda sem duplicar e-mail. Derive o valor do EVENTO que originou a mensagem (pedido-1234-confirmado), nunca de um aleatório: um uuid() gerado na hora da chamada muda a cada tentativa e não protege de nada.

Em lote

Até 100 mensagens numa chamada — é o caminho para volume: cem convites custam uma requisição do limite de 600 por minuto, não cem.

$resultados = $mail->emails()->sendBatch([
    [
        'to' => 'ana@exemplo.com',
        'subject' => 'Como foi seu atendimento?',
        'html' => $html,
        'message_type' => 'marketing',
        'idempotency_key' => 'convite-81',
        'metadata' => ['invitation_id' => 81],
    ],
    // ...
]);

foreach ($resultados as $item) {
    if (isset($item['error'])) {
        // $item['index'], $item['status'] (o HTTP do envio avulso), $item['error']['error']
        continue;
    }

    // $item['index'], $item['id'], $item['status'] ('pending' ou 'suppressed'), $item['replayed']
}
  • Cada item aceita os campos de send(), menos anexo, e mais idempotency_key — a chave é por mensagem, no corpo. O cabeçalho Idempotency-Key é recusado no lote: ele não diria qual das cem já saiu.
  • Desfecho item a item. Suprimida, sem cota, modelo inexistente: o item volta com error e o resto do lote sai. A cota que acaba no meio recusa só as que faltam.
  • Recusa do lote inteiro só quando a estrutura é inválida (422 com o caminho, emails.3.to) ou a conta não pode enviar (503/403) — aí sendBatch() lança ApiException, como o send().
  • Retentativa segura: reenvie o MESMO lote com as mesmas chaves — o que já saiu volta com replayed: true e não sai de novo.

Cancelamento em um clique (marketing)

Gmail e Yahoo exigem List-Unsubscribe + List-Unsubscribe-Post de quem envia em volume. Em mensagem de marketing, mande os seus:

'message_type' => 'marketing',
'headers' => [
    'List-Unsubscribe' => '<https://app.exemplo.com/sair/TOKEN>, <mailto:sair@exemplo.com>',
    'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],

O descadastro continua seu: o WiseData Mail confere a forma e repassa — o clique cai na sua URL, e quem marca a pessoa como descadastrada é a sua aplicação.

  • List-Unsubscribe: entradas <https://...> e/ou <mailto:...> separadas por vírgula.
  • List-Unsubscribe-Post: só List-Unsubscribe=One-Click, e exige um https no List-Unsubscribe — é nele que o provedor faz o POST.
  • Em transactional é recusado (422): num recibo, o botão tiraria a pessoa de uma lista. Sem message_type, vale o tipo padrão da chave.

No Laravel, ponha os dois cabeçalhos no Mailable (headers()) junto com X-WiseData-Message-Type: marketing: o transport os encaminha, e só no transacional declarado os deixa de fora.

Laravel

O provider é descoberto sozinho. Basta o .env:

WISEDATA_MAIL_TOKEN=wdm_abc_def
# WISEDATA_MAIL_SPACE=marca-b
# WISEDATA_MAIL_CONTACT_LISTS=clientes-finances

O bloco Integração de cada espaço, no painel (Configurações › Espaços), traz essas linhas prontas para copiar. O da conta principal, na mesma tela, traz só a das listas: o produto ligado à conta mãe não manda WISEDATA_MAIL_SPACE.

app(\WiseData\Mail\Client::class)->contacts()->upsert([...]);

Para ajustar tudo: php artisan vendor:publish --tag=wisedata-mail-config.

MAIL_MAILER=wisedatamail

MAIL_MAILER=wisedatamail
WISEDATA_MAIL_TOKEN=wdm_abc_def

É só isso. Não é preciso tocar em config/mail.php — o pacote registra o mailer sozinho. Todo Mailable, toda Notification e todo Mail::to() que já existem passam a sair pelo WiseData Mail sem uma linha alterada.

O remetente

O mailer manda o endereço do from da mensagem — no Laravel, o MAIL_FROM_ADDRESS, ou o from() do Mailable — e a API o usa. Ele precisa ser um remetente verificado da conta no WiseData Mail (Configurações → Remetentes); se não for, a API recusa com 422 e nada sai. Um endereço não verificado sairia sem DKIM alinhado, e o provedor de quem recebe trataria como falsificação.

O nome exibido vai junto — o MAIL_FROM_NAME, ou o nome do from() do Mailable — e vence o do cadastro do remetente no WiseData Mail. Sem nome no from(), sai o do cadastro. Nome com @, URL ou domínio (MAIL_FROM_NAME="app.exemplo.com") a API recusaria: o mailer o deixa de fora, sai o do cadastro, e a mensagem não é recusada.

Fora do Laravel, ou para trocar o nome por mensagem, from_name no corpo:

// O mesmo endereço verificado, um nome por cliente.
$mail->emails()->send([
    'to' => 'ana@exemplo.com',
    'from' => 'no-reply@seuproduto.com',
    'from_name' => 'Avcom',
    'reply_to' => 'contato@avcom.com.co',
    'subject' => 'Sua opinião',
    'html' => '<p>...</p>',
]);

Até 70 caracteres, texto puro: @, URL, domínio e quebra de linha voltam validation_failed, porque o nome não passa por verificação e não pode se passar por outro remetente. Vale também com template e em cada item de sendBatch().

Duas saídas quando o MAIL_FROM_ADDRESS da aplicação não é (ou não pode ser) um remetente verificado:

# 1. Deixar a conta decidir: o `from` não vai e sai o remetente padrão da conta.
WISEDATA_MAIL_USE_ACCOUNT_SENDER=true
// 2. Um remetente só para este mailer, em config/mail.php (recurso do Laravel).
'wisedatamail' => [
    'transport' => 'wisedatamail',
    'from' => ['address' => 'avisos@seudominio.com', 'name' => 'Seu Produto'],
],

use_account_sender também vale por mailer, no config/mail.php.

Um segundo mailer, para outro espaço ou outro tipo, aí sim vai no config/mail.php:

'wisedatamail_marketing' => [
    'transport' => 'wisedatamail',
    'space' => 'marca-b',
    'message_type' => 'marketing',
],

Anexo

Funciona como em qualquer mailer do Laravel — nada a configurar:

Mail::to($cliente)->send(new RelatorioMensal($pdf));

// dentro do Mailable
public function attachments(): array
{
    return [Attachment::fromPath($caminho)->as('relatorio-setembro.pdf')];
}

embed() também: a imagem vira anexo embutido e o cid: do HTML aponta para ela, que é o logotipo no cabeçalho.

Dez megabytes somando tudo, dez arquivos por mensagem. O pacote recusa antes de subir, para você não gastar a rede num 422 previsível.

Nome de arquivo é obrigatório. O Symfony aceita parte sem nome; nós não. Inventar anexo-1.bin entregaria ao destinatário um nome que ninguém escolheu.

Quem decide quais extensões passam é o servidor, não o pacote — a lista é do provedor de envio e muda sem aviso. Executável e script são recusados com 422 dizendo qual arquivo.

Vários destinatários ou cópia continuam recusados: um envio por pessoa.

Tipo da mensagem

Quem decide é a chave, pelo padrão escolhido quando ela foi emitida. É por isso que migrar não pede mudança em Mailable nenhum.

Para uma mensagem específica:

public function headers(): Headers
{
    return new Headers(text: ['X-WiseData-Message-Type' => 'marketing']);
}

O cabeçalho é lido e removido — não chega a quem recebe. Os demais X-... da sua aplicação são encaminhados.

Tags e metadados do Laravel

public function envelope(): Envelope
{
    return new Envelope(
        subject: 'Suas contas vencem amanhã',
        tags: ['due-reminders'],
        metadata: ['user_id' => $this->user->id, 'reminder_type' => 'digest'],
    );
}

O Envelope vira TagHeader/MetadataHeader no Symfony, e o transport os manda nos campos tags e metadata da chamada — nunca como cabeçalho do e-mail. É o mesmo que os bridges oficiais do Symfony fazem com Postmark, SendGrid e Mailgun. Eles ficam guardados com a mensagem e voltam em todo evento de webhook dela ($evento->tags, $evento->metadata), para você ligar a devolução ou a entrega ao registro de origem.

Regra Teto
tags 10, até 128 caracteres cada; repetida sai uma vez só
metadados 10 chaves
chave até 40; letras, números, _, . e -, começando por letra ou _
valor texto, número ou booleano, até 255; volta no webhook sempre como texto

Fora disso, o transport recusa antes da rede com WiseDataMailTransportException (error: validation_failed) dizendo o campo.

Não ponha dado pessoal aqui (e-mail, nome, CPF): o metadado fica no banco do Mail e viaja até o seu endpoint de webhook. Use o id interno.

Por que existe: até a 1.1.2 o transport mandava os metadados como cabeçalho X-Metadata-user_id, que a API recusa (o _ não vale em nome de cabeçalho) — e que, se passasse, mostraria o user_id a quem recebe o e-mail.

Cabeçalhos personalizados

Todo X-... que a sua aplicação puser na mensagem vai em headers, conferido localmente com a regra da API:

  • nome só com letras, números e hífen (X-Pedido-Id passa; X-Pedido_Id é recusado);
  • até 10 cabeçalhos, valor até 255 caracteres e sem quebra de linha;
  • List-Unsubscribe e List-Unsubscribe-Post também são encaminhados — veja "Cancelamento em um clique";
  • a família X-SES- é reservada da infraestrutura de envio: o transport a descarta, e a API a recusa se chegar pela chamada crua. Os nomes que o e-mail monta sozinho (From, To, Message-ID, Bcc, ...) também são reservados.

Repetição e e-mail duplicado

O mailer usa retries: 0 de propósito. Repetir um POST de envio é arriscar mandar duas vezes — o servidor pode ter aceitado e caído ao responder. Quem repete é a fila do Laravel, com o backoff dela. Aumentar esse número põe duas camadas tentando a mesma coisa.

Conferir o envio depois do deploy

php artisan wisedata-mail:test voce@exemplo.com
php artisan wisedata-mail:test voce@exemplo.com --mailer=wisedatamail_marketing

Envia na hora (sem fila) pelo mesmo caminho de um Mailable — token, espaço, remetente — um e-mail com assunto PING e corpo PONG (entendido em qualquer idioma), e mostra:

Remetente: avisos@seudominio.com (precisa ser um remetente verificado no WiseData Mail)
Enviando para voce@exemplo.com pelo mailer [wisedatamail]...
Aceito pelo WiseData Mail.

O id da mensagem não é impresso: a prova é o PONG chegar, e o terminal do servidor costuma ir parar em log de deploy. No painel, a mensagem é achada pelo destinatário.

ou o código do erro da API (invalid_api_key, validation_failed com o campo recusado, ...), com saída 1. Funciona mesmo com outro mailer como padrão: o MAIL_MAILER não precisa ser wisedatamail.

Testes da aplicação

use WiseData\Mail\Laravel\Facades\WiseDataMail;

public function test_o_recibo_sai_pelo_wisedata_mail(): void
{
    $mail = WiseDataMail::fake();

    $this->post('/pedidos/1234/pagar');

    $mail->assertSentTo('ana@exemplo.com', fn (array $corpo) => $corpo['subject'] === 'Seu recibo');
    $mail->assertSentCount(1);
}

WiseDataMail::fake() troca só a fronteira HTTP: o mailer wisedatamail e o Client do contêiner continuam os reais, então o teste prova o corpo que sairia para a API — to, subject, html, text, from, attachments, headers, tags, metadata. Cada envio é aceito com um id sequencial (1, 2, ...). Vale também para o mailer que já tinha sido resolvido antes do fake(), e não exige token.

A fake aplica as regras da API (nome de cabeçalho, limites de tags e metadata) e devolve o mesmo 422 validation_failed que ela daria, com errors. Assim um Mailable que a API recusaria quebra no seu CI, e não em produção.

Método
assertSent(?callable) houve envio (que satisfaz o filtro)
assertSentTo($email, ?callable) / assertNotSentTo($email) por destinatário
assertSentCount($n) / assertNothingSent() contagem
failWith($error, $status = 422) as chamadas seguintes são recusadas como a API recusaria
sent(?callable) / requests() os corpos aceitos / todas as chamadas, inclusive de contato

Mail::fake() do Laravel continua servindo para afirmar qual Mailable foi enviado; o WiseDataMail::fake() serve para afirmar o que chega à API.

O mesmo WiseDataMail é a facade do Client: WiseDataMail::contacts()->upsert([...]).

Fora do Laravel

composer require symfony/mailer
use Symfony\Component\Mailer\Mailer;
use WiseData\Mail\Mailer\WiseDataMailTransport;

$mailer = new Mailer(new WiseDataMailTransport(new Client('wdm_abc_def')));

Erros

use WiseData\Mail\Exception\ApiException;
use WiseData\Mail\Exception\TransportException;

try {
    $mail->contacts()->upsert([...]);
} catch (ApiException $e) {
    // A API respondeu não.
    $e->error;    // 'unknown_field', 'insufficient_scope', 'space_required', ...
    $e->status;   // 401, 403, 404, 422
    $e->extra;    // 'field', 'required_scope', 'upgrade_to', conforme o caso
} catch (TransportException $e) {
    // A requisição não chegou: DNS, TLS, tempo esgotado.
}

Escreva o seu if contra $e->error, nunca contra a mensagem. O código é estável; a mensagem sai no idioma do Accept-Language e pode ser reescrita a qualquer momento.

Os principais:

error O que fazer
invalid_api_key conferir o token e se ele não foi revogado
insufficient_scope criar uma chave com o escopo em extra['required_scope']
space_required informar o espaço com forSpace()
space_not_allowed o espaço não está marcado na chave — a da conta principal não alcança nenhum: tire o WISEDATA_MAIL_SPACE ou marque o espaço nela
plan_feature_required o plano da conta não inclui a API
subscription_locked a assinatura não está ativa
account_read_only a conta está em consulta; escrita bloqueada
unknown_field o campo em extra['field'] não existe — ver catalog()->fields()
send_quota_exceeded a cota do ciclo acabou; extra['upgrade_to'] diz o plano que resolve
sending_unavailable a conta ainda não está pronta para enviar — repetir mais tarde
sending_suspended o envio da conta foi suspenso; só o suporte libera
template_not_found nenhum modelo ATIVO com a chave em extra['template']
unknown_variable o modelo não usa a variável; os aceitos vêm em extra['accepted_variables']
missing_variable o modelo usa extra['variable'] e ela não foi enviada
rate_limited passou do limite de requisições (ou o verify() antes do minuto); esperar retryAfter() segundos e repetir
provider_unavailable o provedor de envio pediu para esperar (503); repetir depois de retryAfter() segundos
plan_limit_reached o plano não comporta mais espaços ou domínios; extra['feature'] e extra['limit'] dizem qual
space_not_applicable rota de espaço chamada com X-WiseData-Space — o pacote não manda; aparece só em chamada crua
space_not_found nenhum espaço da conta com esse external_id
space_suspended o envio do espaço está suspenso; ele não pode ser excluído até o suporte resolver
domain_not_found / sender_not_found não existe neste espaço — um domínio de OUTRO espaço também responde assim
sender_domain_not_registered o domínio do from_email não está cadastrado no espaço; domains()->register() antes
validation_failed o corpo foi recusado na validação (422); os campos e as mensagens estão em extra['errors']. O caso mais comum é from que não é remetente verificado. A API devolve error: validation_failed junto com message e errors; com uma API antiga, que respondia sem error, o pacote dá o mesmo código

No mailer, tudo isso chega como WiseDataMailTransportException, que é uma TransportException do Symfony — o job falha e vai para failed_jobs como qualquer outra falha de e-mail. O código continua em $e->error.

Tentativas

429, 5xx e falha de rede são repetidos duas vezes por padrão, com espera dobrando. 4xx de conteúdo (422, 404) não são: repetir um corpo inválido devolve o mesmo erro e gasta o limite da chave.

Dentro de um job que já tem repetição própria, desligue com retries: 0 para as duas não se multiplicarem.

Homologação

Não há nada a configurar neste pacote: quem decide é a chave.

Em Configurações → Integrações, marque "Chave de homologação" ao criar. O mesmo código que roda em produção, apontado para essa chave, tem a chamada aceita por inteiro — mas nenhum e-mail sai, nenhuma cota é consumida e a mensagem aparece em Configurações → Caixa de teste, onde dá para ler o conteúdo montado, conferir os anexos e simular os desfechos.

# .env do ambiente de homologação — só a chave muda
WISEDATA_MAIL_TOKEN=wdm_hml_...

A marca está na chave, e não num parâmetro de enviar(), exatamente para que uma homologação não consiga alcançar um cliente real por esquecimento.

Simular um desfecho na caixa dispara o webhook de verdade, com a mesma assinatura — é assim que se testa o receptor sem esperar uma devolução real acontecer.

Recebendo os eventos (webhook)

O caminho de volta: o WiseData Mail faz um POST no endereço cadastrado em Configurações → Webhooks (o cadastro é só no painel; a API não tem rota para isso). Na criação, a tela mostra uma vez o segredo de assinatura.

No Laravel, pronto

WISEDATA_MAIL_WEBHOOK_SECRET=o-segredo-exibido-na-criacao
WISEDATA_MAIL_WEBHOOK_PATH=webhooks/wisedata-mail

Com o PATH preenchido o pacote registra POST /webhooks/wisedata-mail (nome wisedata-mail.webhook), fora dos grupos web e api — sem CSRF, sessão nem throttle. Vazio, nenhuma rota é aberta. Cadastre no painel a URL completa (https://seuapp.com/webhooks/wisedata-mail).

O receptor:

  1. confere a assinatura e a janela de cinco minutos — 401 se não bater, 500 se o segredo não estiver configurado (a API reenvia, e o lote é processado quando o segredo for preenchido);
  2. responde 204 ao ping do botão Testar, sem disparar nada;
  3. deduplica pelo X-WiseData-Delivery, num Cache::add atômico guardado por um dia — a API reenvia um lote por até doze horas;
  4. descarta eventos simulados na caixa de homologação (is_test), que chegam ao mesmo endereço dos reais — ligue WISEDATA_MAIL_WEBHOOK_ACCEPT_TEST_EVENTS=true só no ambiente de homologação;
  5. dispara um evento Laravel por item e responde 204.

A aplicação só escuta:

use WiseData\Mail\Laravel\Events\WiseDataMailBounced;

final class SuprimirEnderecoDevolvido implements ShouldQueue
{
    public function handle(WiseDataMailBounced $e): void
    {
        $e->event->email;                 // quem
        $e->event->messageId;             // o id devolvido no envio
        $e->event->bounceClassification;  // 'invalid_address', 'mailbox_full', ...
    }
}
type Evento Laravel
processed WiseDataMailProcessed — só se o endpoint o assinou
delivered WiseDataMailDelivered
bounce WiseDataMailBounced
dropped WiseDataMailDropped
suppressed WiseDataMailSuppressed
rendering_failure WiseDataMailRenderingFailed
spamreport WiseDataMailSpamReported
open WiseDataMailOpened
click WiseDataMailClicked
unsubscribe WiseDataMailUnsubscribed
resubscribe WiseDataMailResubscribed
domain.verified WiseDataMailDomainVerified — só se o endpoint o assinou
domain.failed WiseDataMailDomainFailed — idem
domain.degraded WiseDataMailDomainDegraded — idem
qualquer outro WiseDataMailUnknownEvent — um tipo novo da API não some

Nos de domínio, o retrato vem tipado em $e->event->domain (um WiseData\Mail\Webhook\DomainStatus) e o cliente em $e->event->space:

public function handle(WiseDataMailDomainVerified $e): void
{
    Cliente::where('mail_space', $e->event->space)
        ->update(['remetente_proprio_liberado' => $e->event->domain->readyToSend]);
}

Todos ficam em WiseData\Mail\Laravel\Events e carregam $event (um WiseData\Mail\Webhook\WebhookEvent) e $deliveryId.

  • Listener demorado implementa ShouldQueue. A API espera o 2xx por dez segundos; passar disso conta como falha e o lote volta.
  • Listener idempotente por $e->event->id. Se um listener lançar exceção, o receptor libera o lote e devolve erro, a API reenvia, e os eventos que já tinham sido tratados chegam de novo.
  • Correlacionar com o envio: o id que o mailer põe em SentMessage::getMessageId() é o message_id dos eventos. Guarde-o ao lado do seu registro.

Prefere a rota no seu arquivo de rotas? Deixe o PATH vazio e:

use WiseData\Mail\Laravel\Webhook\VerifyWebhookSignature;
use WiseData\Mail\Laravel\Webhook\WebhookController;

Route::post('/webhooks/wisedata-mail', WebhookController::class)
    ->middleware(VerifyWebhookSignature::class)
    ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class);
Variável Padrão
WISEDATA_MAIL_WEBHOOK_SECRET — o segredo de assinatura; sem ele tudo é recusado
WISEDATA_MAIL_WEBHOOK_PATH vazio liga a rota pronta
WISEDATA_MAIL_WEBHOOK_TOLERANCE 300 janela, em segundos
WISEDATA_MAIL_WEBHOOK_DEDUPE_TTL 86400 por quanto tempo um X-WiseData-Delivery é lembrado
WISEDATA_MAIL_WEBHOOK_CACHE_STORE cache padrão com várias instâncias, um cache compartilhado (Redis, banco)
WISEDATA_MAIL_WEBHOOK_ACCEPT_TEST_EVENTS false aceita os eventos simulados na homologação

O contrato

A fonte é a documentação da API do WiseData Mail; o resumo:

Cabeçalhos

Cabeçalho
X-WiseData-Signature sha256= + HMAC-SHA256 hexadecimal de timestamp + "." + corpo cru, com o segredo
X-WiseData-Timestamp horário Unix, em segundos, que entra no material assinado
X-WiseData-Delivery id do lote, igual em todo reenvio; 0 no ping de teste
X-WiseData-Event-Count quantos eventos vêm no corpo

Corpo: até cem eventos por POST.

{
  "events": [
    {
      "id": 8821,
      "type": "delivered",
      "occurred_at": "2026-09-25T14:03:11+00:00",
      "email": "ana@exemplo.com",
      "message_id": 991,
      "campaign_id": null,
      "message_type": "transactional",
      "url": null,
      "reason": null,
      "bounce_classification": null,
      "machine_open": false,
      "is_test": false,
      "space": null
    }
  ],
  "sent_at": "2026-09-25T14:03:21+00:00"
}

O ping do botão Testar é {"test": true, "events": [], "sent_at": "..."}.

Todo evento traz todos os campos, com null nos que não se aplicam:

Campo Quando tem valor
id sempre; cresce, serve para deduplicar e perceber buraco
type sempre — ver a tabela abaixo
occurred_at sempre; ISO 8601 com fuso
email sempre, menos nos domain.* (null; no pacote, '')
message_id sempre, menos nos domain.*; o id devolvido no envio
domain só nos domain.*: {domain, ready_to_send, verified_at, failed_records}
campaign_id na campanha; null no transacional
message_type marketing ou transactional
url só em click
reason em bounce, suppressed, dropped, rendering_failure e spamreport
bounce_classification em bounce, suppressed, dropped e rendering_failure
machine_open em open e click: true quando foi máquina (pré-carregamento, antivírus, verificador de links); false nos demais
ip, user_agent só em open e click (no pacote, $ip e $userAgent); na abertura pelo Gmail, os do proxy de imagens do Google. Dado pessoal de quem recebeu
space slug do espaço de envio; null na conta principal
is_test true quando o desfecho foi simulado na caixa de homologação
type O que aconteceu Campos próprios
processed o provedor aceitou a mensagem — opcional, um por envio —
delivered chegou à caixa do destinatário —
bounce devolveu reason (diagnóstico do servidor), bounce_classification
dropped o provedor recusou antes de tentar (hoje, vírus) reason, bounce_classification: virus_detected
suppressed não saiu: o endereço está na supressão reason, bounce_classification
rendering_failure não foi possível montar a mensagem reason, bounce_classification: render_failure
spamreport marcado como spam reason
open aberto machine_open, ip, user_agent
click clicado url, machine_open, ip, user_agent
unsubscribe descadastrou pela página —
resubscribe voltou a se cadastrar pela página —
domain.verified o domínio de envio ficou pronto — opcional domain, space
domain.failed a conferência automática desistiu sem o domínio ficar pronto — opcional domain (failed_records diz o que falta)
domain.degraded estava pronto e deixou de estar, já confirmado — opcional domain (failed_records diz o que sumiu)

Cada virada do domínio sai uma vez: duas conferências que veem a mesma mudança não geram dois eventos. O domain.failed só sai quando a conferência desiste por tempo, nunca por falha nossa.

processed só chega ao endpoint que o marcou no painel: é um aviso por mensagem enviada, útil para medir o funil (aceito → entregue) e volume demais para quem não precisa. deferred existe no WiseData Mail mas não é entregue por webhook — o provedor tenta de novo sozinho, e o desfecho chega depois.

Os valores de bounce_classification: invalid_address, previously_bounced, on_account_suppression, on_tenant_suppression, failed_validation, mailbox_unavailable (caixa desativada), domain_not_found, blocked_by_policy (filtro de spam ou lista de bloqueio do destino), sender_auth_failed (SPF, DKIM ou DMARC do remetente reprovados), rejected_by_server, sender_check_failed (o filtro de entrada do destino recusou; nenhum adianta repetir); mailbox_full, delivery_timeout, temporary_failure (adianta mais tarde); message_too_large, content_rejected, attachment_rejected, render_failure (só mudando a mensagem); virus_detected; undetermined.

Entrega

  • Qualquer resposta que não seja 2xx é falha: o lote volta com espera crescente (10s, 30s, 1min, 5min, 15min) por até doze horas, com o mesmo X-WiseData-Delivery e o mesmo corpo, byte a byte.
  • Tempo limite de dez segundos. Redirecionamento não é seguido — um 3xx é falha.
  • Vinte lotes falhados seguidos desativam o webhook; os eventos do período desligado não são reenviados.
  • Latência de até cerca de setenta segundos: não é canal de tempo real.
  • Trocar o segredo no painel invalida o anterior na hora.

Fora do Laravel

use WiseData\Mail\Webhook\WebhookPayload;
use WiseData\Mail\Webhook\WebhookSignature;

$corpo = file_get_contents('php://input');

if (! WebhookSignature::verify(
    $corpo,
    $_SERVER['HTTP_X_WISEDATA_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_WISEDATA_SIGNATURE'] ?? '',
    $segredo,
)) {
    http_response_code(401);
    exit;
}

foreach (WebhookPayload::parse($corpo)->events as $evento) {
    // $evento->type, $evento->email, $evento->messageId, $evento->subject, ...
}

$evento->subject é o assunto como saiu para a pessoa (variáveis do modelo trocadas; na campanha, a variante A/B resolvida). Vem null na mensagem de campanha que não saiu (suprimida, recusada) e em mensagem enviada antes de a API passar a guardá-lo (06/10/2026).

Três coisas que dão errado e não parecem defeito:

  • Assine o corpo cru. Decodificar e recodificar o JSON muda a ordem das chaves e o escape, e a conferência deixa de bater. No Laravel, $request->getContent(), nunca $request->all().
  • Compare em tempo constante — o verify() usa hash_equals.
  • Deduplique pelo X-WiseData-Delivery. O reenvio leva o mesmo id e o mesmo corpo.

Outra biblioteca HTTP

O padrão é cURL para o pacote não impor escolha a ninguém. Quem já usa Guzzle ou o cliente do Laravel implementa WiseData\Mail\Contracts\Transport.

No Laravel, registre no contêiner — o Client e o mailer passam a usá-lo:

// AppServiceProvider::register()
$this->app->singleton(\WiseData\Mail\Contracts\Transport::class, fn () => new MeuTransporte);

Fora dele:

new Client(token: '...', transport: new MeuTransporte);

Testes do pacote

composer install
vendor/bin/phpunit                    # tudo, com Laravel (orchestra/testbench)
vendor/bin/phpunit --testsuite core   # só o que não depende de Laravel

Não há chamada de rede na suíte. O CI roda Symfony Mailer 6.4 (sem Laravel), 7.x (Laravel 12) e 8.x (Laravel 13).

Licença

MIT.