wisedata / mail-php
Cliente PHP da API do WiseData Mail: contatos e envio de e-mail.
Requires
- php: ^8.3
- ext-curl: *
- ext-json: *
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.5.8
- symfony/mailer: ^6.4|^7.0|^8.0
Suggests
- symfony/mailer: Necessário apenas para o transport de e-mail (MAIL_MAILER=wisedatamail). Toda aplicação Laravel já o tem.
Provides
None
Conflicts
- symfony/mailer: <6.4
Replaces
None
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) edomain.degraded(estava pronto e deixou de estar). Não consultefind()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_limitedcomretryAfter().- O remetente da API nunca vira o padrão do espaço: informe o
fromno 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$mailde 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 maisidempotency_key— a chave é por mensagem, no corpo. O cabeçalhoIdempotency-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
errore 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çaApiException, como osend(). - Retentativa segura: reenvie o MESMO lote com as mesmas chaves — o que já saiu
volta com
replayed: truee 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 umhttpsnoList-Unsubscribe— é nele que o provedor faz o POST.- Em
transactionalé recusado (422): num recibo, o botão tiraria a pessoa de uma lista. Semmessage_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 ouser_ida 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-Idpassa;X-Pedido_Idé recusado); - até 10 cabeçalhos, valor até 255 caracteres e sem quebra de linha;
List-UnsubscribeeList-Unsubscribe-Posttambé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:
- confere a assinatura e a janela de cinco minutos —
401se não bater,500se o segredo não estiver configurado (a API reenvia, e o lote é processado quando o segredo for preenchido); - responde
204ao ping do botão Testar, sem disparar nada; - deduplica pelo
X-WiseData-Delivery, numCache::addatômico guardado por um dia — a API reenvia um lote por até doze horas; - descarta eventos simulados na caixa de homologação (
is_test), que chegam ao mesmo endereço dos reais — ligueWISEDATA_MAIL_WEBHOOK_ACCEPT_TEST_EVENTS=truesó no ambiente de homologação; - 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 o2xxpor 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
idque o mailer põe emSentMessage::getMessageId()é omessage_iddos 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 mesmoX-WiseData-Deliverye 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()usahash_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.