Como construí a newsletter do blog: double opt-in e envio em lote
Os detalhes técnicos por trás do botão de inscrição na newsletter do blog. Double opt-in com token, confirmação por e-mail sem precisar de login, e envio em lote para os inscritos usando Resend.
O blog ganhou uma newsletter há pouco tempo, aquele campo de e-mail que aparece no fim dos posts e num card lateral. Já expliquei aqui como enviar e-mail com Node sem cair no spam, de forma mais geral, mas ficou faltando mostrar como isso funciona de verdade nesse caso específico: inscrição, confirmação e envio em lote pros novos posts.
Por que double opt-in
O jeito mais simples de implementar inscrição seria salvar o e-mail direto no banco assim que alguém envia o formulário. O problema é que esse formulário é público, então nada impede alguém de digitar o e-mail de outra pessoa ali, seja por engano, seja de propósito, cadastrando alguém que nunca pediu pra receber nada.
Double opt-in resolve isso adicionando uma segunda confirmação: a inscrição só vira válida depois que o dono daquele e-mail clica num link de confirmação que chega na caixa de entrada dele. Até isso acontecer, o cadastro fica com status pending, sem receber nenhuma notificação de post novo.
O fluxo, do clique no botão até confirmado
Quando alguém envia o e-mail no formulário, a rota de inscrição verifica se aquele e-mail já existe na tabela e, se não existir, cria um registro novo com um token aleatório:
app.post("/newsletter/subscribe", async (c) => {
const { email } = await c.req.json();
const normalizedEmail = email.toLowerCase().trim();
const [existing] = await db
.select()
.from(newsletterSubscribers)
.where(eq(newsletterSubscribers.email, normalizedEmail));
const confirmToken = crypto.randomUUID();
if (existing) {
await db
.update(newsletterSubscribers)
.set({ confirmToken })
.where(eq(newsletterSubscribers.id, existing.id));
} else {
await db.insert(newsletterSubscribers).values({
email: normalizedEmail,
confirmToken,
status: "pending",
});
}
await sendConfirmationEmail(normalizedEmail, confirmToken);
return c.json({ ok: true });
});Repara que, mesmo se o e-mail já existir, um token novo é gerado. Isso evita que alguém, sabendo o e-mail de outra pessoa, tente adivinhar ou reaproveitar um link de confirmação antigo que já vazou em algum lugar.
O e-mail de confirmação leva um link direto pra rota de confirmação, com esse token na URL:
app.get("/newsletter/confirm", async (c) => {
const token = c.req.query("token");
if (!token) return c.redirect("/?newsletter=invalid");
const [subscriber] = await db
.select()
.from(newsletterSubscribers)
.where(eq(newsletterSubscribers.confirmToken, token));
if (!subscriber) return c.redirect("/?newsletter=invalid");
await db
.update(newsletterSubscribers)
.set({ status: "confirmed", confirmedAt: new Date() })
.where(eq(newsletterSubscribers.id, subscriber.id));
await sendWelcomeEmail(subscriber.email, subscriber.unsubscribeToken);
return c.redirect("/?newsletter=confirmed");
});Só depois desse clique o status muda pra confirmed, e um e-mail de boas-vindas confirma pro usuário que a inscrição foi ativada de verdade.
Token em vez de exigir login
Um detalhe importante desse fluxo é que nada disso exige o visitante criar conta nem fazer login. Tanto a confirmação quanto o cancelamento de inscrição funcionam só com o token, gerado uma vez e embutido no link do e-mail:
app.get("/newsletter/unsubscribe", async (c) => {
const token = c.req.query("token");
const [subscriber] = await db
.select()
.from(newsletterSubscribers)
.where(eq(newsletterSubscribers.unsubscribeToken, token));
if (!subscriber) return c.redirect("/?newsletter=invalid");
await db
.update(newsletterSubscribers)
.set({ status: "unsubscribed", unsubscribedAt: new Date() })
.where(eq(newsletterSubscribers.id, subscriber.id));
return c.redirect("/?newsletter=unsubscribed");
});Isso mantém a experiência simples: um clique confirma, um clique cancela, sem senha, sem fricção nenhuma pra quem só quer parar de receber os avisos.
Envio em lote com Resend
A parte que muda de verdade em escala é notificar todo mundo quando um post novo sai. Mandar um e-mail por vez, num loop simples, funciona pra poucos inscritos, mas fica lento e arriscado à medida que a lista cresce. O Resend tem um endpoint de batch, que aceita vários e-mails numa única chamada, e o envio é feito em blocos:
const BATCH_CHUNK_SIZE = 100;
export async function sendNewPostBatch(subscribers, post) {
let sent = 0;
for (let i = 0; i < subscribers.length; i += BATCH_CHUNK_SIZE) {
const chunk = subscribers.slice(i, i + BATCH_CHUNK_SIZE);
const { error } = await getResend().batch.send(
chunk.map((subscriber) => ({
from: FROM,
to: subscriber.email,
subject: `Novo post: ${post.title}`,
html: montarEmail(post, subscriber.unsubscribeToken),
})),
);
if (error) throw new Error(`Resend: ${error.message}`);
sent += chunk.length;
}
return sent;
}Quebrar em blocos de 100 respeita o limite da própria API do Resend por chamada, e ainda deixa o processo mais resiliente: se um bloco falhar, os anteriores já foram enviados, em vez de perder o progresso inteiro numa única tentativa gigante.
Cada e-mail enviado nesse lote carrega o próprio unsubscribeToken do destinatário, então o link de cancelamento no rodapé de cada mensagem já sai pronto e individual, sem precisar de nenhuma etapa extra depois.
Dashboard simples pra acompanhar
Do lado administrativo, existe uma tela simples contando quantos inscritos estão confirmados, pendentes ou cancelados, com busca por e-mail e ação de disparar a notificação de um post específico pra quem já confirmou. Nada sofisticado, só o suficiente pra acompanhar o crescimento da lista sem precisar abrir o banco de dados direto toda vez.
Fechando
Nenhuma peça individual desse sistema é complicada isoladamente, um token aleatório, duas rotas de redirect, um envio em lote. O que faz isso funcionar bem junto é o double opt-in garantindo que só quem realmente quis se inscreve, e o token substituindo qualquer necessidade de login pra confirmar ou cancelar. É basicamente o mesmo princípio usado em recuperação de senha, aplicado aqui pra resolver um problema de consentimento, não de autenticação.
Direto na sua
caixa de entrada.
Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.
Leia também
- Edge functions: o que muda rodar código mais perto do usuário18 de setembro de 2026 · 4 min
- Versionamento de API: como mudar um contrato sem quebrar quem já usa17 de setembro de 2026 · 4 min
- Filas de mensagem: por que desacoplar processamento pesado da requisição16 de setembro de 2026 · 4 min
- GraphQL vs REST: quando cada um faz sentido15 de setembro de 2026 · 4 min