nota técnica

Un formulario de contacto sin una sola dependencia

28 de agosto de 2026 · sobre el formulario de este mismo sitio

El formulario de Hablemos manda correo de verdad. Para hacerlo no hay instalada ninguna librería: el proyecto entero tiene cero dependencias de npm en tiempo de ejecución, y eso incluye el envío de correo.

Cada dependencia externa que se añade a un proyecto trae consigo una cierta carga que hay que mantener. Si se puede obtener lo mismo sin dependencies y con esfuerzo menor, a la larga paga dividendos.

El SDK que no se instaló

El proveedor de correo publica su SDK oficial. Revisando la documentación de dicho SDK, es una llamata http simple: una petición POST con una cabecera de autorización y un objeto JSON. La plataforma ya trae fetch.

POST https://api.resend.com/emails
Authorization: Bearer <clave>

{ "from": ..., "to": ..., "subject": ..., "text": ..., "reply_to": ... }

El adaptador que envuelve esa llamada ocupa un archivo. Está aislado a propósito: cambiar de proveedor es reescribir ese archivo, sin tocar el formulario ni el resto del servidor.

La regla que aplicamos es la misma en todos los proyectos: antes de añadir una librería revisamos si ya lo hace el lenguaje, la plataforma o unas pocas líneas. Aquí lo hacía la plataforma.

Lo que valida el navegador es comodidad, no un control

El formulario valida en el navegador porque avisa antes de enviar y eso se agradece. No cuenta como seguridad: cualquiera puede mandar la petición directamente, sin pasar por la página.

Por eso el servidor lo vuelve a comprobar todo, con límites explícitos:

El detalle que evita que te secuestren el formulario

Un correo se compone de cabeceras separadas por saltos de línea. Si un valor que viene de fuera lleva un salto de línea dentro, deja de ser un valor y se convierte en una cabecera nueva. Así se secuestra un formulario de contacto para mandar spam en nombre de otro.

La defensa es aburrida y funciona: los valores de configuración se rechazan si contienen un salto de línea, y la dirección de quien escribe nunca se usa como remitente. Va en Reply-To, que es donde sirve para responder y donde no controla nada.

Si falta una variable de configuración, la aplicación no arranca. Preferimos eso a que arranque a medias y el primer mensaje se pierda sin que nadie se entere.

Un límite de envíos que cabe en la memoria del proceso

Hay dos contadores:

Al superarlos la respuesta es 429 con Retry-After, y el mensaje no llega a salir. Los contadores viven en la memoria del proceso: no hay Redis, ni base de datos, ni servicio externo.

Es suficiente porque el sitio corre en una sola máquina. Un almacén compartido habría sumado una dependencia sin comprar exactitud.

El límite que hay que decir en voz alta: con más de una instancia, cada una aplicaría su propio contador, y el límite efectivo sería el número por el de instancias. Está escrito en el propio módulo, para que quien escale el sitio lo lea antes de que le sorprenda.

La cabecera en la que solo se confía en el sitio correcto

Para contar por IP hay que saber la IP. Detrás de la plataforma de despliegue, la dirección del socket es la del proxy, y la del visitante viene en una cabecera que el proxy escribe.

El problema es evidente en cuanto se enuncia: una cabecera la puede inventar cualquiera. Si el servidor se la cree siempre, saltarse el límite es cambiar un valor en cada petición.

Así que esa cabecera solo se lee cuando el proceso está corriendo en esa plataforma, detectado por una variable que la propia plataforma inyecta. Fuera de ahí se ignora y se usa la dirección del socket.

La trampa que sí se dejó puesta

Hay un campo oculto que una persona nunca llena. Cuando llega con contenido, el servidor responde 200 y no envía nada. El bot cree que lo consiguió y no reintenta.

Sin CAPTCHA. Un CAPTCHA le cobra la molestia a todos los visitantes reales para resolver un problema que todavía no existe. Si algún día el spam pasa, entonces se evalúa.

Qué se gana con todo esto