Formulario de contacto: anti-spam y entregabilidad sin fricción

← Volver
Pantalla con terminal y logs, útil para diagnosticar fallos de envío y asegurar que el lead no se pierde
Sin logs, reintentos y una cola mínima, un lead perdido suele ser invisible.

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:

  1. Se acepta (no lo filtra el anti-spam por error).
  2. Se procesa (no se cae una función / webhook).
  3. 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–3s desde form_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:

  • message con límite (p. ej. 2.000–5.000 caracteres).
  • email validado 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 queued se 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.com o contacto@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=none si 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 (con reason)

Ratios útiles:

  • accepted / received → calidad del anti-spam (falsos positivos vs abuso)
  • sent / accepted → salud del worker/proveedor
  • delivered / 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):

  1. POST /contact (función)
  • valida
  • anti-spam
  • persiste
  • encola
  • responde 200
  1. 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)

  1. P0: honeypot + rate limit + validación server + persistencia + microcopy correcto.
  2. P1: cola + reintentos + alertas + (si aplica) métricas de “delivered”.
  3. 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.