Tema
Formulario de contacto seguro
El formulario de contacto es lo que más piden los clientes... y lo que más spam recibe. Este snippet combina validación en servidor, token CSRF, honeypot, tiempo mínimo de llenado y límite de envíos, guarda el mensaje en la base de datos y avisa por correo.
Capas de protección
| Capa | Contra qué protege |
|---|---|
| Validación en servidor | Datos vacíos, correos falsos, textos gigantes |
| Token CSRF | Envíos desde otros sitios |
| Honeypot (campo oculto) | Bots que rellenan todos los campos |
| Tiempo mínimo (3 s) | Bots que envían al instante |
| Límite por sesión/IP | Inundación de mensajes |
htmlspecialchars al mostrar | XSS en el panel o en el correo |
Tabla (opcional pero recomendada)
sql
CREATE TABLE mensajes_contacto (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL,
telefono VARCHAR(30) NULL,
asunto VARCHAR(150) NOT NULL,
mensaje TEXT NOT NULL,
ip VARCHAR(45) NULL,
leido TINYINT(1) DEFAULT 0,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Código completo
php
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/mailer.php';
$errores = [];
$exito = flash('contacto_ok');
$datos = ['nombre' => '', 'email' => '', 'telefono' => '', 'asunto' => '', 'mensaje' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
// 1) Honeypot: un humano nunca ve ni llena este campo
if (!empty($_POST['sitio_web'])) {
flash('contacto_ok', '¡Gracias! Te responderemos pronto.'); // fingimos éxito
redirect('contacto.php');
}
// 2) Tiempo mínimo de llenado
$inicio = (int) ($_POST['t'] ?? 0);
if (time() - $inicio < 3) {
$errores['general'] = 'Envío demasiado rápido. Inténtalo de nuevo.';
}
// 3) Límite: máximo 3 mensajes por hora por sesión
$_SESSION['envios'] = array_filter($_SESSION['envios'] ?? [], fn($t) => $t > time() - 3600);
if (count($_SESSION['envios']) >= 3) {
$errores['general'] = 'Has enviado varios mensajes. Intenta más tarde o escríbenos por WhatsApp.';
}
// 4) Validación de campos
foreach ($datos as $campo => $_) {
$datos[$campo] = trim((string) ($_POST[$campo] ?? ''));
}
if (mb_strlen($datos['nombre']) < 2 || mb_strlen($datos['nombre']) > 100) {
$errores['nombre'] = 'Escribe tu nombre (2 a 100 caracteres).';
}
if (!filter_var($datos['email'], FILTER_VALIDATE_EMAIL)) {
$errores['email'] = 'Escribe un correo válido.';
}
if ($datos['telefono'] !== '' && !preg_match('/^[0-9 +()-]{8,20}$/', $datos['telefono'])) {
$errores['telefono'] = 'Teléfono no válido.';
}
if ($datos['asunto'] === '' || mb_strlen($datos['asunto']) > 150) {
$errores['asunto'] = 'Escribe un asunto.';
}
if (mb_strlen($datos['mensaje']) < 10 || mb_strlen($datos['mensaje']) > 3000) {
$errores['mensaje'] = 'El mensaje debe tener entre 10 y 3000 caracteres.';
}
// Spam típico: muchos enlaces
if (substr_count(strtolower($datos['mensaje']), 'http') > 2) {
$errores['mensaje'] = 'El mensaje contiene demasiados enlaces.';
}
// Evita inyección de cabeceras en el correo
if (preg_match('/[\r\n]/', $datos['nombre'] . $datos['email'] . $datos['asunto'])) {
$errores['general'] = 'Datos no válidos.';
}
if (!$errores) {
// 5) Guardar
db()->prepare('INSERT INTO mensajes_contacto (nombre, email, telefono, asunto, mensaje, ip)
VALUES (?, ?, ?, ?, ?, ?)')
->execute([...array_values($datos), $_SERVER['REMOTE_ADDR'] ?? null]);
// 6) Avisar al dueño del sitio (respondiendo directo al cliente)
$html = '<p><b>Nombre:</b> ' . e($datos['nombre']) . '</p>'
. '<p><b>Correo:</b> ' . e($datos['email']) . '</p>'
. '<p><b>Teléfono:</b> ' . e($datos['telefono'] ?: '—') . '</p>'
. '<p><b>Mensaje:</b><br>' . nl2br(e($datos['mensaje'])) . '</p>';
enviar_correo(env('CONTACTO_DESTINO', env('MAIL_FROM')), 'Nuevo mensaje: ' . $datos['asunto'], $html, [
'responder_a' => $datos['email'],
]);
// 7) Confirmación automática al cliente (opcional)
enviar_correo($datos['email'], 'Recibimos tu mensaje', '<p>Hola ' . e($datos['nombre']) . ', gracias por escribirnos. Te responderemos en menos de 24 horas hábiles.</p>');
$_SESSION['envios'][] = time();
flash('contacto_ok', '¡Gracias! Tu mensaje fue enviado. Te responderemos pronto.');
redirect('contacto.php');
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contacto</title>
<style>
body { font-family: system-ui, sans-serif; background: #f8fafc; margin: 0; padding: 32px 16px; }
form { max-width: 560px; margin: auto; background: #fff; padding: 28px; border-radius: 14px; box-shadow: 0 10px 30px #0001; }
label { display: block; font-weight: 600; margin-top: 14px; }
input, textarea { width: 100%; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; box-sizing: border-box; }
.error { color: #b91c1c; font-size: 14px; }
.ok { background: #dcfce7; color: #166534; padding: 12px; border-radius: 8px; }
.hp { position: absolute; left: -9999px; } /* honeypot fuera de la vista */
button { margin-top: 18px; background: #4f46e5; color: #fff; border: 0; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<form method="post" novalidate>
<h1>Contáctanos</h1>
<?php if ($exito): ?><p class="ok"><?= e($exito) ?></p><?php endif; ?>
<?php if (isset($errores['general'])): ?><p class="error"><?= e($errores['general']) ?></p><?php endif; ?>
<?= csrf_field() ?>
<input type="hidden" name="t" value="<?= time() ?>">
<!-- Honeypot: oculto para humanos, tentador para bots -->
<div class="hp" aria-hidden="true">
<label>Tu sitio web <input type="text" name="sitio_web" tabindex="-1" autocomplete="off"></label>
</div>
<label for="nombre">Nombre</label>
<input id="nombre" name="nombre" value="<?= e($datos['nombre']) ?>" required>
<span class="error"><?= e($errores['nombre'] ?? '') ?></span>
<label for="email">Correo</label>
<input id="email" type="email" name="email" value="<?= e($datos['email']) ?>" required>
<span class="error"><?= e($errores['email'] ?? '') ?></span>
<label for="telefono">Teléfono (opcional)</label>
<input id="telefono" type="tel" name="telefono" value="<?= e($datos['telefono']) ?>">
<span class="error"><?= e($errores['telefono'] ?? '') ?></span>
<label for="asunto">Asunto</label>
<input id="asunto" name="asunto" value="<?= e($datos['asunto']) ?>" required>
<span class="error"><?= e($errores['asunto'] ?? '') ?></span>
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="5" required><?= e($datos['mensaje']) ?></textarea>
<span class="error"><?= e($errores['mensaje'] ?? '') ?></span>
<button>Enviar mensaje</button>
</form>
</body>
</html>Cómo integrarlo
- Requiere conexión PDO,
helpers.phpdel snippet de autenticación y PHPMailer. - Agrega
CONTACTO_DESTINO=ventas@tudominio.comal.env. - Si tu landing es HTML estático, cambia la extensión a
.phpo apunta elactiondel formulario acontacto.php.
¿Aún llega spam?
Agrega Cloudflare Turnstile o reCAPTCHA v3. Ambos se verifican en el servidor con una petición a su API antes de guardar el mensaje.
Error común
Poner el correo del cliente en setFrom(). Los servidores lo detectan como suplantación y el mensaje termina en spam. El remitente siempre es tu dominio; el correo del cliente va en responder_a.