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.