Skip to content

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 ​

CapaContra qué protege
Validación en servidorDatos vacíos, correos falsos, textos gigantes
Token CSRFEnví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/IPInundación de mensajes
htmlspecialchars al mostrarXSS 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 ​

  1. Requiere conexión PDO, helpers.php del snippet de autenticación y PHPMailer.
  2. Agrega CONTACTO_DESTINO=ventas@tudominio.com al .env.
  3. Si tu landing es HTML estático, cambia la extensión a .php o apunta el action del formulario a contacto.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.

Academia Web — aprende, copia y construye.