Construindo um SaaS do zero, parte 6: e-mails transacionais
Sexta parte da série: e-mails de boas-vindas e de pagamento falho com Resend, Supabase e webhooks do Stripe, sem repetir o mesmo aviso duas vezes.
Sumário do artigo

Na parte 5 da série o produto foi ao ar, e a série prometeu tratar dos primeiros usuários. O primeiro contato com eles acontece por e-mail, e é nele que esta parte se concentra: o que o app precisa enviar sozinho, de qual endereço, e como impedir que o mesmo aviso chegue duas vezes. A medição do que os usuários fazem fica para a parte 7.
Na parte 4 a tabela de eventos do Stripe tinha uma linha que dizia "avisar o usuário" quando o pagamento falha. Aqui essa linha vira código: um domínio de envio verificado, uma função no Supabase que manda o e-mail e a ligação com os webhooks que você já tem.
Dois tipos de e-mail, dois caminhos
Existem duas famílias de mensagem num SaaS, e misturá-las é a origem de metade dos problemas de entrega.
| Família | Exemplos | Quem envia |
|---|---|---|
| Autenticação | Confirmação de cadastro, recuperação de senha, link mágico | Supabase Auth, usando o SMTP configurado no projeto |
| Produto | Boas-vindas, pagamento falhou, ação necessária no pagamento | Uma edge function sua, chamando a API do Resend |
O SMTP padrão do Supabase serve para teste. A documentação diz que o Auth recusa entregar mensagens para endereços que não fazem parte do time do projeto e que o limite é de 2 mensagens por hora. Depois de configurar um SMTP próprio, o limite inicial sobe para 30 mensagens por hora e pode ser ajustado na página de Rate Limits. Essa é a configuração de produção que a parte 5 listou como "SMTP próprio", agora com valores.
Domínio de envio
O Resend recomenda enviar de um ou mais subdomínios em vez do domínio raiz. O motivo é isolar a reputação de envio e deixar clara a intenção para quem recebe. Se o seu site é seudominio.com.br, use algo como envio.seudominio.com.br e mantenha o e-mail da empresa fora desse caminho.
O processo de verificação:
- No painel do Resend, adicione o domínio de envio.
- Copie os registros da aba Records. O Resend pede configurações de DKIM e SPF, entregues como registros
TXTeMXouCNAME, e os valores precisam ser exatamente os gerados para o seu domínio. - Crie os registros no provedor de DNS, do mesmo jeito que você fez com A e CNAME na parte 5.
- Aguarde. A verificação costuma terminar em até 15 minutos, mas a propagação de DNS pode levar até 72 horas. Se passar disso, use Restart verification no painel.
Se o seu DNS fica no Cloudflare, desligue o proxy (a nuvem laranja) nos registros CNAME. Com o proxy ligado a verificação não completa.
O DMARC não faz parte da verificação inicial. Adicione depois que o domínio estiver verificado, para reforçar a confiança no domínio e proteger contra falsificação.
SMTP do Supabase Auth com o Resend
Os e-mails de autenticação passam a sair pelo mesmo domínio verificado. Nas configurações de autenticação do Supabase, ative o SMTP personalizado com estes valores:
| Campo | Valor |
|---|---|
| Host | smtp.resend.com |
| Porta | 465 |
| Usuário | resend |
| Senha | A sua chave de API do Resend |
| E-mail e nome do remetente | Obrigatórios, no domínio verificado |
Depois de salvar, crie uma conta de teste com um e-mail que não seja o seu e confirme que a mensagem chega. Esse teste não passava no SMTP padrão.
O módulo compartilhado de envio
As duas funções desta parte, a de boas-vindas e o webhook do Stripe, precisam enviar e-mail. Em vez de uma chamada HTTP de uma função para outra, coloque a lógica num módulo em supabase/functions/_shared/email.ts, importado pelas duas.
// supabase/functions/_shared/email.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!;
const REMETENTE = 'Seu SaaS <ola@envio.seudominio.com.br>';
const sb = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
export type Pedido = {
tipo: 'boas_vindas' | 'pagamento_falhou' | 'acao_necessaria';
para: string;
chave: string; // identifica este envio e impede repetição
dados: Record<string, string>;
};
const esc = (s: string) =>
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const modelos: Record<Pedido['tipo'], (d: Record<string, string>) => { assunto: string; html: string }> = {
boas_vindas: (d) => ({
assunto: `Sua conta no ${d.produto} está pronta`,
html: `<p>Oi, ${esc(d.nome)}.</p>
<p>O primeiro passo é ${esc(d.primeiro_passo)}.</p>
<p><a href="${esc(d.link)}">Abrir o app</a></p>
<p>Se travar em qualquer ponto, responda este e-mail.</p>`,
}),
pagamento_falhou: (d) => ({
assunto: 'Não conseguimos cobrar a sua assinatura',
html: `<p>Oi, ${esc(d.nome)}.</p>
<p>A cobrança de ${esc(d.valor)} não passou. ${esc(d.proxima_tentativa)}</p>
<p>Seu acesso continua ativo enquanto isso. Para trocar o cartão: <a href="${esc(d.link)}">abrir minha conta</a>.</p>`,
}),
acao_necessaria: (d) => ({
assunto: 'Falta confirmar o pagamento da sua assinatura',
html: `<p>Oi, ${esc(d.nome)}.</p>
<p>O seu banco pediu uma confirmação para liberar a cobrança de ${esc(d.valor)}.</p>
<p><a href="${esc(d.link)}">Confirmar pagamento</a></p>`,
}),
};
export async function enviarEmail(p: Pedido) {
// 1. Reserva a chave. Se já existe, este envio já aconteceu.
const { error: reserva } = await sb
.from('emails_enviados')
.insert({ chave: p.chave, tipo: p.tipo, para: p.para });
if (reserva?.code === '23505') return { duplicado: true };
if (reserva) throw new Error(`reserva: ${reserva.message}`);
// 2. Envia. Em ambiente de teste, EMAIL_DESTINO_FORCADO redireciona tudo.
const destino = Deno.env.get('EMAIL_DESTINO_FORCADO') ?? p.para;
const modelo = modelos[p.tipo](p.dados);
const resp = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': p.chave,
},
body: JSON.stringify({
from: REMETENTE,
to: [destino],
subject: modelo.assunto,
html: modelo.html,
}),
});
// 3. Se falhou, libera a chave para a próxima tentativa funcionar.
if (!resp.ok) {
await sb.from('emails_enviados').delete().eq('chave', p.chave);
throw new Error(`resend ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
}
const { id } = await resp.json();
await sb.from('emails_enviados').update({ resend_id: id }).eq('chave', p.chave);
return { id };
}
Três decisões nesse código merecem explicação.
A função esc escapa o que vem do usuário antes de entrar no HTML. Um nome cadastrado como <img src=x> viraria uma tag dentro do e-mail se você interpolasse direto.
A chave de idempotência aparece duas vezes, de propósito. O cabeçalho Idempotency-Key da API do Resend protege contra repetição dentro de 24 horas e aceita até 256 caracteres. O Stripe, no modo live, reenvia um webhook com falha por até 3 dias, então uma repetição no segundo dia passaria pelo cabeçalho. A tabela com chave única cobre esse buraco.
Se o envio falha, a linha da tabela é apagada antes do erro subir. Sem isso, a tentativa seguinte do Stripe encontraria a chave reservada e pularia o e-mail que nunca saiu.
A tabela:
create table emails_enviados (
chave text primary key,
tipo text not null,
para text not null,
resend_id text,
enviado_em timestamptz default now()
);
alter table emails_enviados enable row level security;
-- sem policies: só a chave de serviço, que ignora RLS, lê e escreve
Cadastre os segredos em Edge Functions, na área de Secrets do painel, ou pela CLI: supabase secrets set RESEND_API_KEY=re_xxx.
Boas-vindas quando o cadastro existe
O gatilho mais confiável fica no banco. Se o envio dependesse do front, um usuário que fecha a aba logo depois de criar a conta nunca receberia nada.
Os Database Webhooks do Supabase disparam em INSERT, UPDATE e DELETE e são um invólucro sobre triggers que usa a extensão pg_net. A chamada HTTP é assíncrona, então não trava a gravação enquanto espera uma resposta lenta. O corpo enviado tem cinco campos: type, table, schema, record e old_record. Num INSERT, record traz a linha nova e old_record vem nulo.
Crie um webhook para INSERT na tabela que guarda o perfil do usuário, apontando para https://SEU-PROJETO.supabase.co/functions/v1/boas-vindas, com um cabeçalho x-webhook-secret e um valor que só você conhece. A função:
// supabase/functions/boas-vindas/index.ts
import { enviarEmail } from '../_shared/email.ts';
Deno.serve(async (req) => {
if (req.headers.get('x-webhook-secret') !== Deno.env.get('WEBHOOK_SECRET')) {
return new Response('proibido', { status: 403 });
}
const { type, record } = await req.json();
if (type !== 'INSERT' || !record?.email) return new Response('ignorado');
await enviarEmail({
tipo: 'boas_vindas',
para: record.email,
chave: `boas_vindas:${record.id}`,
dados: {
nome: record.nome ?? 'por aí',
produto: 'Seu SaaS',
primeiro_passo: 'criar o seu primeiro projeto',
link: 'https://app.seudominio.com.br',
},
});
return new Response('ok');
});
Ajuste email, nome e id para os nomes de coluna da sua tabela. A chave boas_vindas:ID garante um único e-mail por usuário, mesmo que o webhook dispare duas vezes.
Ligando aos eventos do Stripe
Na função stripe-webhook da parte 4, o bloco que grava o estado já existe. O envio entra logo depois dele, dentro do mesmo switch, depois da checagem do event.id:
import { enviarEmail } from '../_shared/email.ts';
// dentro do switch (event.type) da parte 4
case 'invoice.payment_failed': {
const fatura = event.data.object as Stripe.Invoice;
if (!fatura.customer_email) break;
const proxima = fatura.next_payment_attempt
? `Vamos tentar de novo em ${new Date(fatura.next_payment_attempt * 1000).toLocaleDateString('pt-BR')}.`
: 'Atualize o cartão para manter a assinatura.';
await enviarEmail({
tipo: 'pagamento_falhou',
para: fatura.customer_email,
chave: `pagamento_falhou:${event.id}`,
dados: {
nome: fatura.customer_name ?? '',
valor: `${(fatura.amount_due / 100).toFixed(2)} ${fatura.currency.toUpperCase()}`,
proxima_tentativa: proxima,
link: 'https://app.seudominio.com.br/conta',
},
});
break;
}
O link aponta para a página /conta do app, onde o botão abre o portal do cliente da parte 4. Gerar a sessão do portal na hora do clique evita mandar por e-mail um endereço que pode vencer antes de o usuário abrir a mensagem.
O caso invoice.payment_action_required segue o mesmo molde, com o tipo acao_necessaria, a chave acao_necessaria:${event.id} e um link para a tela do app onde o pagamento é confirmado.
Os eventos que merecem e-mail, segundo a documentação do Stripe:
| Evento | |
|---|---|
invoice.payment_failed |
Pagamento falhou. A documentação lista "notify the customer" como primeira ação recomendada |
invoice.payment_action_required |
Ação necessária. O banco pediu autenticação, e o app precisa avisar o cliente para ele concluir |
customer.subscription.trial_will_end |
Opcional. O Stripe envia 3 dias antes do fim do teste, e a documentação sugere confirmar que há forma de pagamento e, se quiser, avisar sobre a cobrança |
Quando a assinatura vira past_due, o Stripe recomenda avisar o cliente diretamente e pedir a atualização dos dados de pagamento. Isso combina com a regra da parte 4: o acesso continua liberado em past_due, e o e-mail é o aviso que a interface sozinha não entrega.
O Stripe também envia recibos por conta própria quando essa opção está ligada nas configurações do painel. Confira antes de criar um e-mail de recibo seu, para o cliente não receber dois.
O que escrever em cada e-mail
Texto curto, um único botão e um remetente que o usuário reconhece.
Boas-vindas: o nome, um passo concreto e a informação de que responder o e-mail chega a uma pessoa. Um passo concreto leva o usuário mais longe que um tour pelo produto.
Pagamento falhou: o valor, quando haverá nova tentativa (o campo next_payment_attempt da fatura, quando existe), o que acontece com o acesso nesse intervalo e um link para trocar o cartão. Mantenha o tom direto e sem ameaça, na linha da tolerância que a parte 4 adotou para o past_due.
Ação necessária: uma frase dizendo que o banco pediu confirmação e um botão para concluir. Só isso.
Falhas de entrega
Mandar e-mail para endereço inexistente prejudica a reputação do domínio de envio. O Resend publica eventos de webhook para isso, entre eles email.bounced, email.complained, email.delivery_delayed, email.failed e email.suppressed.
O mínimo útil: receba email.bounced e email.complained numa função, marque o usuário com um campo como email_invalido e pare de enviar para ele. Verifique a assinatura do webhook como você fez com o do Stripe na parte 4, seguindo o que a documentação do Resend indica para esse passo.
Sobre limite de uso, a API do Resend aceita por padrão 10 requisições por segundo por equipe e responde 429 quando o teto é ultrapassado, com o cabeçalho retry-after indicando quantos segundos esperar. Para e-mails disparados por evento num SaaS pequeno o teto sobra. Se o envio falhar com 429 dentro do webhook do Stripe, devolva um erro 500 para o Stripe tentar de novo, e a reserva apagada no módulo deixa a nova tentativa passar. Para mais contexto sobre retry e timeout em integrações, veja Rate limit, timeout e retry: o que quebra quando seu app de IA ganha usuários.
Testar antes de abrir
O Resend tem endereços de teste que simulam cada resultado, e eles contam na sua cota de envio:
| Endereço | O que simula |
|---|---|
delivered@resend.dev |
Entrega bem-sucedida |
bounced@resend.dev |
Rejeição pelo provedor do destinatário, com SMTP 550 5.1.1 (Unknown User) |
complained@resend.dev |
Mensagem recebida e marcada como spam |
suppressed@resend.dev |
Envio suprimido porque o endereço já teve bounce |
Os três primeiros aceitam rótulos depois do +, como delivered+usuario1@resend.dev, o que ajuda a separar testes diferentes.
No ambiente local, defina EMAIL_DESTINO_FORCADO=delivered@resend.dev para que nenhum evento de teste do Stripe tente escrever para um cliente de mentira. Depois rode os eventos reais pela CLI, como na parte 4:
stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
stripe trigger invoice.payment_failed
Rode o mesmo stripe trigger duas vezes e confira a tabela emails_enviados. Cada evento novo gera uma linha. A repetição do mesmo evento não gera e-mail nenhum.
Erros comuns
| Erro | Consequência | Correção |
|---|---|---|
| Enviar do domínio raiz | Reputação do e-mail da empresa misturada com a do produto | Subdomínio de envio |
| Manter o SMTP padrão do Supabase em produção | Entrega só para o time do projeto, 2 mensagens por hora | SMTP próprio com domínio verificado |
| Proxy do Cloudflare ligado nos CNAME | Domínio nunca completa a verificação | Desligar o proxy nesses registros |
Confiar só no Idempotency-Key |
Repetição do Stripe depois de 24 horas gera e-mail duplicado | Tabela com chave única |
| Disparar boas-vindas pelo front | Quem fecha a aba nunca recebe | Database Webhook no INSERT |
| Colocar dado do usuário direto no HTML | Tag injetada dentro do e-mail | Escapar tudo que vem de fora |
| Mandar e-mail de recibo e deixar o do Stripe ligado | Cliente recebe dois | Escolher um dos dois |
O que fazer agora
- Adicione um subdomínio de envio no Resend, crie os registros no DNS e espere o status de verificado.
- Configure o SMTP personalizado no Supabase com
smtp.resend.com, porta 465 e usuárioresend, e teste com uma conta que não seja do time. - Crie a tabela
emails_enviadose o módulo_shared/email.ts, cadastreRESEND_API_KEYnos segredos. - Crie o Database Webhook de
INSERTno perfil e a funçãoboas-vindas. - Inclua
invoice.payment_failedeinvoice.payment_action_requirednostripe-webhook, rodestripe triggerduas vezes e confira que só um e-mail saiu. - Use os endereços
@resend.devpara testar bounce e reclamação antes de abrir para clientes reais.
Com os e-mails no ar, a parte 7 passa para a pergunta que decide o que construir depois: quem se cadastrou, quem chegou à primeira ação útil e quem voltou na semana seguinte, medidos com SQL direto no Supabase.
Não perca a próxima edição.
Toda quinta, 9h. Direto na sua caixa.
- Ferramentas que economizam horas do seu trabalho
- Agentes e automações que funcionam
- Bastidores do que estamos construindo