Back to all posts
Tutorial

Construir un sistema de boletín multilingüe de costo cero con Cloudflare: Workers, D1, Resend y autenticación HMAC

Cada creador independiente y bloguero enfrenta el mismo problema: ¿cómo permitir que los visitantes se suscriban a tus actualizaciones sin pagar $20/mes por un servicio de terceros ni confiar tus datos de usuarios a alguien más?

Cuando decidí agregar un boletín a mi blog, tenía tres requisitos innegociables:

  1. Costo cero — todo en niveles gratuitos o auto-hospedado
  2. Control total — sin dependencia de cajas negras de terceros; los datos de los suscriptores residen en mi propia base de datos
  3. Seguridad de nivel producción — enlaces de cancelación firmados con HMAC para evitar que los visitantes se cancelen entre sí
  4. Soporte multilingüe — correos de bienvenida y páginas de cancelación localizados en 7 idiomas

Construí todo este sistema en la pila de Cloudflare y te mostraré exactamente cómo.


Arquitectura: pila gratuita, sin compromisos

La pila tecnológica completa:

  • Cloudflare Workers (support-api.elvisli.ca): backend sin estado, maneja suscripción, cancelación, APIs administrativas
  • Cloudflare D1: base de datos SQLite que almacena suscriptores y metadatos de geolocalización
  • Cloudflare Pages: frontend para dashboard administrativo y páginas de cancelación en 7 idiomas
  • Resend: API de correo gratuita (280 correos/día) para envíos transaccionales
  • Web Crypto API: HMAC-SHA256 nativo del navegador y Workers para firma de tokens

Costo total: $0/mes. (Si excedes el plan gratuito de Resend, actualiza a la versión pagada; sin costo operativo.)


Diseño de base de datos: suscriptores + geolocalización

El corazón es una única tabla subscribers en D1:

CREATE TABLE IF NOT EXISTS subscribers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  source TEXT DEFAULT 'home',           -- fuente de suscripción
  lang TEXT,                             -- preferencia idiomática del usuario
  country TEXT, region TEXT, city TEXT, -- geolocalización inyectada por Cloudflare
  timezone TEXT,                         -- completado automáticamente por CF
  ip TEXT, user_agent TEXT,              -- para auditoría
  status TEXT DEFAULT 'active',          -- 'active' o 'unsubscribed'
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_status ON subscribers(status);

¿Por qué este diseño?

  • Email UNIQUE: evita duplicados; los re-suscritos fallan silenciosamente con un falso éxito (anti-spam)
  • Columnas geográficas: Cloudflare Workers puede acceder a request.cf en la periferia, así que obtienes ciudad/país/zona horaria gratis — sin necesidad de API de búsqueda de IP de terceros
  • Campo Status: patrón de soft-delete; desuscribirse solo establece status='unsubscribed', no elimina la fila (preserva auditoría)
  • Campo source: rastrea la fuente de suscripción (página de inicio, reenvío por correo, etc.)

Backend: API de Workers

El backend es un único archivo Worker que maneja tres endpoints:

1. POST /api/subscribe — Nueva suscripción

Cuerpo de solicitud:

{
  "email": "user@example.com",
  "lang": "es",
  "source": "home",
  "website": "",              // trampa (campo oculto)
  "elapsedMs": 5000           -- milisegundos desde carga hasta envío del formulario
}

Flujo:

async function handleSubscribe(request, env) {
  const body = await request.json();
  const { email, lang, source, website, elapsedMs } = body;

  // 1. Anti-spam: rechazo silencioso de bots
  if (website || elapsedMs < 1200) {
    return fakeSuccess(); // los bots obtienen un 200 falso sin saber
  }

  // 2. Validación de correo
  if (!isValidEmail(email)) return error400("Correo inválido");

  // 3. Capturar geolocalización desde Cloudflare
  const cf = request.cf || {};
  const country = cf.country || request.headers.get("CF-IPCountry");
  const timezone = cf.timezone;
  const ip = request.headers.get("CF-Connecting-IP");

  // 4. Insertar en D1 (restricción UNIQUE previene duplicados)
  const result = await env.DB.prepare(
    `INSERT OR IGNORE INTO subscribers (email, lang, source, country, timezone, ip)
     VALUES (?, ?, ?, ?, ?, ?)`
  ).bind(email, lang, source, country, timezone, ip).run();
  
  const isNew = result.meta.changes > 0;

  // 5. Enviar correo de bienvenida (localizado, solo para nuevos)
  if (isNew) {
    const unsubToken = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
    const unsubUrl = `https://elvisli.ca/${localePrefix(lang)}/unsubscribe?email=${email}&token=${unsubToken}`;
    
    await sendResendEmail({
      to: email,
      subject: welcomeCopy(lang).subject,
      html: welcomeEmailHtml(lang, unsubUrl),
    });

    // También notificar al administrador del sitio
    await sendResendEmail({
      to: env.NOTIFY_EMAIL,
      subject: `📨 Nuevo suscriptor: ${email}`,
      html: adminNotificationHtml(email, country, timezone, lang),
    });
  }

  return ok({ success: true, isNew, subscribed: true });
}

Puntos clave:

  • Trampa + guardián temporal: los bots completan el campo website oculto o envían en < 1,2 segundos. Ambos retornan falso éxito (200 pero sin inserción), así que los bots no vuelven a intentar.
  • Geolocalización desde request.cf: Cloudflare lo completa automáticamente en la periferia; sin llamada API de terceros.
  • Generación de token HMAC: se genera en el momento de la suscripción e incrusta en la URL de cancelación, se envía por correo.

2. POST /api/unsubscribe — Cancelación de autoservicio

Cuerpo de solicitud:

{
  "email": "user@example.com",
  "token": "a3f8d2c6..."  -- HMAC-SHA256(email, secret)
}

Flujo:

async function handleUnsubscribe(request, env) {
  const { email, token } = await request.json();

  // Verificar firma HMAC
  const expected = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
  if (token !== expected) {
    return error403("Enlace de cancelación inválido o expirado");
  }

  // Marcar como desuscrito
  await env.DB.prepare(
    `UPDATE subscribers SET status = 'unsubscribed' WHERE email = ?`
  ).bind(email).run();

  return ok({ success: true });
}

¿Por qué HMAC en lugar de token de un solo uso?

  • Sin estado: sin necesidad de almacenar una tabla de "tokens de cancelación" con expiración. La firma en sí es la prueba.
  • Vinculado al correo: HMAC(email, secret) siempre produce la misma firma para ese correo. No puedes desuscribir attacker@evil.com con la firma para victim@example.com.
  • Comparación en tiempo constante: compara firmas vía XOR para prevenir ataques de cronometraje.

Correo de bienvenida multilingüe

El correo de bienvenida es crítico para la incorporación y debe:

  1. Establecer confianza — profesional, con marca, honesto
  2. Establecer expectativas — qué recibirán y con qué frecuencia
  3. Ofrecer una salida — enlace de cancelación bien visible
const WELCOME_I18N = {
  es: {
    subject: "Suscripción confirmada — elvisli.ca",
    heading: "Ya estás dentro.",
    body: "Gracias por suscribirte. Recibirás artículos nuevos sobre IA, desarrollo y algún experimento — directo a tu correo. Sin spam.",
    cta: "Leer lo último →",
    footer: "Recibes este correo porque este email se suscribió en elvisli.ca.",
    notyou: "¿No fuiste tú?",
    unsub: "Cancelar suscripción al instante",
  },
  // ... otros idiomas
};

Notas de diseño:

  • Fondo claro: los correos oscuros se marcan a menudo como spam. Fondo claro, texto negro, buen contraste.
  • Color de énfasis verde lima (#A3E635): coincide con el lenguaje de diseño del sitio.
  • Línea "¿No fuiste tú?" prominente: rojo, negrita, en la parte inferior — si alguien abusa inscribiendo la dirección de otro visitante, ese visitante ve inmediatamente que puede darse de baja.

Página de cancelación (7 idiomas)

Cada página de cancelación (/[locale]/unsubscribe) es de solo lectura:

export default function UnsubscribePage() {
  const [email, setEmail] = useState('');
  const [token, setToken] = useState('');
  const [status, setStatus] = useState('init');

  useEffect(() => {
    // El correo y token son de solo LECTURA desde el enlace firmado
    const params = new URLSearchParams(window.location.search);
    setEmail(params.get('email'));
    setToken(params.get('token'));
  }, []);

  const handleUnsubscribe = async () => {
    const res = await fetch('https://support-api.elvisli.ca/api/unsubscribe', {
      method: 'POST',
      body: JSON.stringify({ email, token }),
    });
    
    if (res.ok) setStatus('done');
    else if (res.status === 403) setStatus('invalid');
  };

  return (
    <div>
      <p>Estás a punto de darte de baja de:</p>
      {/* El correo está bloqueado y es de solo lectura */}
      <div style={{ background: '#f5f5f5', padding: '11px', borderRadius: '8px', fontFamily: 'monospace' }}>
        {email}
      </div>
      <button onClick={handleUnsubscribe}>Confirmar cancelación</button>
    </div>
  );
}

¿Por qué solo lectura?

El token HMAC está vinculado a este correo exacto. Si el usuario lo cambia a attacker@evil.com, la firma ya no coincide, y el backend retorna 403. Así que un campo editable era:

  1. Confuso — los usuarios pensaban que podían cambiar de correo
  2. Innecesario — el backend lo rechazaría

Bloquéalo, reduce la carga cognitiva.


Dashboard administrativo

La pestaña "Suscriptores" del dashboard permite:

  • Ver todo: correo, ubicación (ciudad/región/país), zona horaria, idioma, origen, fecha de suscripción
  • Buscar y filtrar: por correo, ubicación, idioma
  • Modo edición: haz clic en "Editar" para revelar el botón eliminar (doble botón para prevenir eliminaciones accidentales)
  • Eliminar: suscriptores únicos
<table>
  <thead>
    <tr>
      <th>Correo</th>
      <th>Ubicación</th>
      <th>Idioma</th>
      <th>Origen</th>
      <th>Se suscribió</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr id="sub-row-5">
      <td>elvis@example.com</td>
      <td>Toronto, Ontario, CA</td>
      <td><span class="badge">es</span></td>
      <td>home</td>
      <td>2026-06-18 12:34:56</td>
      <td>
        <button onclick="toggleSubEdit(5)">Editar</button>
        <span id="sub-actions-5" style="display:none;">
          <button onclick="deleteSubscriber(5)">Eliminar</button>
          <button onclick="toggleSubEdit(5)">Cancelar</button>
        </span>
      </td>
    </tr>
  </tbody>
</table>

El botón eliminar está oculto por defecto (solo muestra después de "Editar"), evitando errores.


Lista de verificación de despliegue

  1. Crear base de datos D1:

npx wrangler d1 create newsletter-db
npx wrangler d1 execute newsletter-db --remote --file=schema.sql
  • Establecer secretos:

  • npx wrangler secret put ADMIN_TOKEN
    npx wrangler secret put UNSUBSCRIBE_SECRET
    npx wrangler secret put RESEND_API_KEY
    npx wrangler secret put NOTIFY_EMAIL
    
  • Desplegar el Worker:

  • npx wrangler deploy
    
  • Probar:

  • curl -X POST https://support-api.elvisli.ca/api/subscribe \
      -H "Content-Type: application/json" \
      -d '{"email":"test@example.com","lang":"es","source":"test"}'
    

    Demo en vivo: Suscríbete en elvisli.ca/es y revisa el correo de bienvenida.