Voltar ao blog

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.

C
Caio Braga
13 de outubro de 2026 · 12 min de leitura
Sumário do artigo
Construindo um SaaS do zero, parte 6: e-mails transacionais

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:

  1. No painel do Resend, adicione o domínio de envio.
  2. Copie os registros da aba Records. O Resend pede configurações de DKIM e SPF, entregues como registros TXT e MX ou CNAME, e os valores precisam ser exatamente os gerados para o seu domínio.
  3. Crie os registros no provedor de DNS, do mesmo jeito que você fez com A e CNAME na parte 5.
  4. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

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 E-mail
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

  1. Adicione um subdomínio de envio no Resend, crie os registros no DNS e espere o status de verificado.
  2. Configure o SMTP personalizado no Supabase com smtp.resend.com, porta 465 e usuário resend, e teste com uma conta que não seja do time.
  3. Crie a tabela emails_enviados e o módulo _shared/email.ts, cadastre RESEND_API_KEY nos segredos.
  4. Crie o Database Webhook de INSERT no perfil e a função boas-vindas.
  5. Inclua invoice.payment_failed e invoice.payment_action_required no stripe-webhook, rode stripe trigger duas vezes e confira que só um e-mail saiu.
  6. Use os endereços @resend.dev para 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.

Tags
#saas#e-mail transacional#resend#supabase#stripe#jornada saas
● Não perca essa chance

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

100% gratuito. Cancele quando quiser.

Compartilhar