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:
- Costo cero — todo en niveles gratuitos o auto-hospedado
- Control total — sin dependencia de cajas negras de terceros; los datos de los suscriptores residen en mi propia base de datos
- Seguridad de nivel producción — enlaces de cancelación firmados con HMAC para evitar que los visitantes se cancelen entre sí
- 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.cfen 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
websiteoculto 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 desuscribirattacker@evil.comcon la firma paravictim@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:
- Establecer confianza — profesional, con marca, honesto
- Establecer expectativas — qué recibirán y con qué frecuencia
- 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:
- Confuso — los usuarios pensaban que podían cambiar de correo
- 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
-
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.

