Formulario de contacto: anti-spam y entregabilidad sin fricción
Publicado el
Formulario de contacto: anti-spam y entregabilidad sin fricción
En la serie de #formularios ya vimos cómo reducir fricción (UX) y cómo medir el embudo. El siguiente escalón es menos “visible”, pero igual o más caro cuando falla: fiabilidad.
Un formulario “convierte” solo si el mensaje:
- Se acepta (no lo filtra el anti-spam por error).
- Se procesa (no se cae una función / webhook).
- Te llega (no se queda en “enviado” sin aterrizar en tu bandeja).
En este post te dejo una checklist P0/P1 para:
- Frenar spam sin CAPTCHA intrusivo.
- Evitar pérdidas silenciosas (colas, reintentos, deduplicación).
- Mejorar entregabilidad de notificaciones (SPF/DKIM/DMARC + proveedor SMTP/API + pruebas rápidas), con métricas mínimas para priorizar por impacto.
1. Qué significa “fiable” en un formulario de contacto
Piensa el formulario como un pipeline (aunque uses serverless):
Usuario → Validación → Anti-spam → Persistencia → Cola → Envío (email/CRM) → Observabilidad
Cuando algo falla, suele ser por uno de estos motivos:
- Spam y abuso: te llenan el inbox o saturan el endpoint.
- Fallo silencioso: el usuario ve “OK”, pero el backend no persiste o no notifica.
- Entregabilidad: el email se marca como sospechoso o se pierde entre filtros.
- Duplicados: reintentas sin idempotencia y recibes 2–5 leads iguales.
- Operación: no hay logs útiles, no hay alertas, no hay forma rápida de saber “qué se rompió”.
Objetivo realista (y medible): que el formulario tenga señales en cada paso: recibido, aceptado, en cola, procesado, notificado, error.
2. Checklist P0 (imprescindible): anti-spam y “no perder leads”
2.1 Honeypot + heurística de tiempo (sin castigar UX)
- Campo honeypot oculto (no con
display:none, mejor off-screen /aria-hidden) y si se rellena, no procesas. - Regla de tiempo mínima: si el submit llega en
< 2–3sdesdeform_start, sospechoso. - Si marcas como spam, responde 200/OK igualmente (evitas que el atacante “aprenda” tu filtro).
Ejemplo de honeypot accesible:
<label class="sr-only" aria-hidden="true">
Deja este campo vacío
<input type="text" name="company_website" tabindex="-1" autocomplete="off" />
</label>
2.2 Rate limiting (por IP + ventana corta)
- Límite simple: por ejemplo 5 envíos / 10 min / IP (ajusta a tu caso).
- Límite adicional por fingerprint “suave” (user-agent + IP) si te atacan desde proxies.
- Respuesta para el usuario: clara y sin drama.
Microcopy recomendado:
- “Has enviado demasiados mensajes en poco tiempo. Reintenta en unos minutos.”
2.3 Validación “doble”: cliente para UX, servidor para seguridad
- Cliente: formatos y campos requeridos (para reducir errores).
- Servidor: todo lo importante (para evitar bypass).
- Sanitización y límites: tamaño máximo de mensaje, lista de campos permitidos, etc.
Reglas típicas que evitan sustos:
messagecon límite (p. ej. 2.000–5.000 caracteres).emailvalidado y normalizado.- Denegar HTML si no lo necesitas (evita payloads raros).
2.4 Persistencia primero, notificación después (no al revés)
Si el “lead” solo existe en un email, lo vas a perder.
- Persistes el envío (DB / KV / storage / proveedor de formularios) antes de enviar notificación.
- Devuelves al usuario un “OK” solo si el lead está guardado.
- Si no puedes persistir en DB aún: al menos guarda en un “sink” estable (por ejemplo, un formulario gestionado o un webhook que almacene).
2.5 Idempotencia / deduplicación básica
- Generas
submission_id(UUID) en cliente o servidor. - En reintentos, usas ese ID para no crear duplicados.
Heurística mínima: hash de email + mensaje + fecha_redondeada_5min. No es perfecta, pero te salva del caos.
2.6 Respuestas y errores que no rompen confianza
Reutiliza microcopy consistente (idealmente igual al resto del sitio).
- Incompleto: “Revisa los campos marcados. Hay información obligatoria pendiente.”
- Red: “No se pudo conectar. Revisa tu conexión y vuelve a intentarlo.”
- Timeout: “La operación tardó más de lo esperado. Reintenta en unos segundos.”
- 500: “Algo falló por nuestra parte. Ya lo estamos revisando.”
Y para éxito:
- Confirmación inmediata y concreta: “Recibido. En breve te respondo con los siguientes pasos.”
3. Checklist P1 (mejoras): colas, reintentos, alertas y canales alternativos
3.1 Cola + worker (o “asimetría” simple)
Si envías email dentro de la misma request, pagas con:
- Timeouts.
- Errores intermitentes.
- “Fantasmas” difíciles de reproducir.
P1 recomendado:
- Request de formulario solo valida + persiste + encola.
- Un worker procesa la cola: notifica por email / CRM / Slack.
- Reintentos con backoff: 1m, 5m, 30m, 2h (según criticidad).
3.2 Reintentos con backoff + dead letter
- Reintentas solo ante errores transitorios (timeouts, 429, 5xx).
- Tras N intentos, pasas a “dead letter” (estado
failed) y alertas.
3.3 Observabilidad útil (sin guardar datos de más)
- Log estructurado con
submission_id,status,provider_response,timing_ms. - No logueas el mensaje completo si no hace falta (RGPD + minimización).
- Retención corta y defendible (y alineada con tu política de privacidad).
3.4 Alertas accionables
- Alerta si
failed> X en 15 min. - Alerta si
queuedse acumula (cola crece). - Alerta si no hay “submissions” en horas pico (síntoma de caída total o tracking roto).
3.5 “No dependas solo del email”
Email es un canal, no un sistema.
- Notificación secundaria: Slack/Discord/Telegram o un panel simple.
- Fallback: si falla el proveedor de email, deja el lead “visible” (dashboard o lista interna).
4. Entregabilidad mínima (sin humo): que los avisos lleguen
Aquí hay un error clásico: enviar el email “desde” el email del usuario (por ejemplo From: usuario@gmail.com). Eso hoy suele acabar mal por autenticación (DMARC) y reputación.
Regla práctica:
- From: tu dominio (p. ej.
no-reply@tudominio.comocontacto@tudominio.com) - Reply-To: el email del usuario (para responder con un clic)
4.1 SPF, DKIM y DMARC (mínimo viable)
- SPF: autoriza a tu proveedor (SMTP/API) a enviar en nombre de tu dominio.
- DKIM: firma tus emails para que el receptor verifique integridad.
- DMARC: define política y reportes (empieza con
p=nonesi estás aterrizando).
Si usas un proveedor SMTP/API, su panel te da los registros DNS exactos. Lo importante es:
- que queden alineados con el dominio desde el que envías,
- y que no tengas “dos verdades” (varios proveedores enviando desde el mismo From sin control).
4.2 Elige un proveedor de envío (SMTP o API) con reputación
Para notificaciones transaccionales, suele merecer la pena usar un proveedor dedicado en vez de “enviar desde tu hosting”:
- mejor reputación,
- métricas,
- y webhooks de eventos (entregado / bounce).
4.3 Pruebas rápidas en bandejas (10 minutos)
- Envío a Gmail + Outlook/Hotmail + una cuenta corporativa si tienes.
- Revisa spam/promociones.
- Comprueba headers básicos (SPF/DKIM/DMARC pass).
- Si algo falla, ajusta From, autenticación y contenido (menos links, sin adjuntos, sin frases “spammy”).
Tip: si mandas auto-respuesta al usuario (“Recibido…”), mantenla corta, sin adjuntos y sin 4 links.
5. Métricas mínimas “enviado / entregado / error” (para priorizar)
Si no puedes instrumentar todo, instrumenta esto:
Eventos (mínimos):
contact_submit_received(servidor recibe)contact_submit_accepted(pasa filtros + persiste)contact_notification_sent(aceptado por proveedor)contact_notification_delivered(si tienes webhook)contact_notification_failed(conreason)
Ratios útiles:
accepted / received→ calidad del anti-spam (falsos positivos vs abuso)sent / accepted→ salud del worker/proveedordelivered / sent→ entregabilidad real (si mides)
En P0, muchas veces te quedas en sent (aceptado por proveedor). Es mejor que nada, pero no lo confundas con “entregado”.
6. Implementación de referencia (serverless): patrón con cola + reintentos
Este es un esquema típico (adaptable a tu stack):
- POST /contact (función)
- valida
- anti-spam
- persiste
- encola
- responde 200
- worker (cron o cola gestionada)
- consume
- envía notificación
- marca estado
- reintenta si toca
Pseudo-código de la función:
import { randomUUID } from "crypto";
export async function handler(req) {
const now = Date.now();
const body = JSON.parse(req.body ?? "{}");
const submissionId = body.submission_id ?? randomUUID();
// 1) Honeypot
if (body.company_website) {
return ok(); // 200, no filtrar
}
// 2) Time heuristic
if (typeof body.form_started_at === "number") {
const delta = now - body.form_started_at;
if (delta < 2000) return ok();
}
// 3) Rate limit (pseudo)
// if (await isRateLimited(req.ip)) return tooMany();
// 4) Validate
const { email, message } = body;
if (!isValidEmail(email) || !message || message.length > 5000) {
return badRequest("Revisa los campos marcados.");
}
// 5) Persist first
await saveSubmission({
submissionId,
email,
message,
status: "accepted",
createdAt: now,
});
// 6) Enqueue
await enqueueJob({ submissionId });
return ok({ submission_id: submissionId });
}
function ok(payload = {}) {
return { statusCode: 200, body: JSON.stringify({ ok: true, ...payload }) };
}
Worker (muy simplificado):
export async function processJob(job) {
const submission = await loadSubmission(job.submissionId);
try {
// From: tu dominio
// Reply-To: email del usuario
const providerRes = await sendEmail({
from: "contacto@tudominio.com",
replyTo: submission.email,
subject: `Nuevo lead: ${submission.email}`,
text: submission.message,
});
await markSubmission(job.submissionId, {
status: "notified",
providerMessageId: providerRes.id,
});
} catch (err) {
const retryable = isRetryable(err);
await markSubmission(job.submissionId, {
status: retryable ? "retry" : "failed",
error: safeError(err),
});
if (!retryable) notifyAlert(job.submissionId);
throw err; // para que el sistema de cola reintente si aplica
}
}
No hace falta montar esto “perfecto” para que aporte. La mejora grande suele ser persistir + cola + logs.
7. Orden recomendado para aplicarlo (sin eternizarte)
- P0: honeypot + rate limit + validación server + persistencia + microcopy correcto.
- P1: cola + reintentos + alertas + (si aplica) métricas de “delivered”.
- Entregabilidad: SPF/DKIM/DMARC + From/Reply-To bien puestos + pruebas rápidas.
Si quieres revisar tu formulario actual (UX + medición + fiabilidad) como un sistema completo, empieza por el tag: #formularios y, si lo prefieres, puedes ir directo al formulario de contacto para pedirme una auditoría.