Cómo enviar correos desde PHP puro utilizando Sendmail en Debian 13: formulario HTML, validación y seguridad

Serie: Sendmail en Debian — Parte 6 de 6
Durante esta serie aprendimos a instalar Sendmail en Debian 13, configurarlo con Gmail y servidores SMTP de hosting, administrar sus registros y automatizar mensajes mediante Bash.
Ahora utilizaremos Sendmail desde una aplicación web desarrollada con PHP puro.
Crearemos un formulario de contacto que permitirá a los visitantes escribir su nombre, dirección de correo electrónico y mensaje. PHP recibirá esa información, comprobará que sea válida y utilizará Sendmail para procesar el correo.
También aprenderemos a crear mensajes HTML, interpretar el resultado de la función mail() y aplicar medidas básicas de seguridad.
Nuestro objetivo es comprender cómo se relacionan las diferentes tecnologías:
VISITANTE
|
v
FORMULARIO HTML
|
v
PHP
|
| Validación de datos
| Protección CSRF
| Control de frecuencia
|
v
FUNCIÓN mail()
|
v
SENDMAIL
|
v
SMTP CONFIGURADO
Gmail o hosting
|
v
SERVIDOR DEL DESTINATARIO
|
v
BUZÓN DE CORREO
Una ventaja de esta arquitectura es que PHP no necesita almacenar directamente la contraseña SMTP de Gmail o del hosting.
Sendmail se encarga de utilizar la configuración de autenticación que preparamos anteriormente.
1. ¿Qué aprenderemos?
En este tutorial realizaremos las siguientes prácticas:
- Instalar PHP y preparar un entorno de pruebas.
- Comprender cómo funciona la función
mail(). - Enviar nuestro primer correo desde PHP.
- Crear un formulario de contacto con HTML y CSS.
- Validar los datos recibidos mediante PHP.
- Incorporar protección CSRF y medidas contra envíos repetidos.
- Enviar mensajes HTML utilizando cabeceras MIME.
- Consultar los errores de PHP y Sendmail.
- Preparar el proyecto para un servidor web.
Trabajaremos con archivos separados para que resulte más sencillo comprender y mantener nuestro código.
2. Requisitos
Necesitaremos:
- Debian GNU/Linux 13.
- Sendmail instalado y configurado.
- Una cuenta SMTP funcional, como Gmail o la de nuestro hosting.
- PHP.
- Un navegador web.
- Un editor de código.
- Acceso a la terminal.
Importante: este tutorial supone que Sendmail ya puede enviar correos externos correctamente.
Si todavía no funciona, debemos revisar las partes anteriores de la serie.
2.1. Instalar PHP
Ejecutamos:
sudo apt update
sudo apt install php-cli php-mbstring
Explicación
sudo permite ejecutar instrucciones con privilegios administrativos.
apt es el administrador de paquetes de Debian.
update actualiza la información de paquetes disponibles.
install instala los paquetes indicados.
php-cli permite ejecutar PHP desde la terminal.
php-mbstring proporciona funciones para trabajar con cadenas de caracteres multibyte.
Comprobamos la versión:
php -v
-v solicita mostrar la versión instalada.
También verificamos que PHP encuentre Sendmail:
command -v sendmail
Podemos obtener:
/usr/sbin/sendmail
3. ¿Qué es la función mail() de PHP?
PHP proporciona una función llamada mail() que permite entregar un mensaje al sistema de correo configurado.
Su sintaxis básica es:
mail($destinatario, $asunto, $mensaje);
Explicación
mail()
Es una función integrada de PHP.
$destinatario
Contiene la dirección de correo a la que queremos enviar el mensaje.
$asunto
Contiene el asunto.
$mensaje
Contiene el cuerpo del mensaje.
También podemos proporcionar cabeceras adicionales:
mail(
$destinatario,
$asunto,
$mensaje,
$cabeceras
);
Estas cabeceras permiten establecer el remitente, el tipo de contenido y otros datos.
3.1. ¿PHP se conecta directamente a Gmail?
No necesariamente.
En nuestro proyecto, PHP entregará el mensaje al programa Sendmail instalado en Debian.
Después Sendmail utilizará el servidor SMTP que configuramos previamente.
Por ejemplo:
PHP
|
v
/usr/sbin/sendmail
|
v
smtp.gmail.com:587
|
v
STARTTLS + autenticación
|
v
Destinatario
Si anteriormente configuramos HostGator, Sendmail utilizará ese servidor en lugar de Gmail.
Por eso no necesitamos escribir las credenciales SMTP dentro del código PHP.
3.2. ¿Qué devuelve mail()?
La función devuelve un valor booleano:
true
o:
false
true significa que el sistema local de correo aceptó el mensaje para su procesamiento.
false significa que PHP no pudo entregar correctamente el mensaje al mecanismo de correo configurado.
Es fundamental comprender que true no garantiza que el mensaje haya llegado a la bandeja de entrada del destinatario.
4. Comprobar la configuración de PHP
Antes de programar, comprobaremos qué comando utiliza PHP para enviar mensajes.
Ejecutamos:
php -i | grep -i sendmail_path
Explicación
php -i muestra información sobre la configuración de PHP.
| envía la salida al siguiente comando.
grep busca texto.
-i ignora diferencias entre mayúsculas y minúsculas.
sendmail_path es la directiva que deseamos localizar.
Podemos obtener:
sendmail_path => /usr/sbin/sendmail -t -i
¿Qué significa?
/usr/sbin/sendmail es el ejecutable utilizado.
-t permite obtener los destinatarios de las cabeceras del mensaje.
-i evita interpretar una línea que contiene solamente un punto como final del mensaje al recibirlo por la entrada estándar.
En Debian, PHP puede utilizar este comando para entregar los correos al MTA local.
Si la directiva está vacía o apunta a un programa inexistente, debemos revisar la configuración.
Atención: PHP CLI y PHP utilizado por Apache o PHP-FPM pueden cargar archivos php.ini diferentes. Por eso, una prueba correcta desde la terminal no garantiza que la aplicación web tenga exactamente la misma configuración.
5. Nuestro primer correo desde PHP
Crearemos un directorio de trabajo.
mkdir -p ~/proyectos/php-sendmail
Explicación
mkdir crea directorios.
-p permite crear los directorios intermedios necesarios.
~ representa nuestro directorio personal.
Entramos:
cd ~/proyectos/php-sendmail
Ahora creamos:
nano prueba-mail.php
Escribimos:
<?php
$destinatario = "[email protected]";
$remitente = "[email protected]";
$asunto = "Prueba de PHP y Sendmail";
$mensaje = "Hola.\n\n"
. "Este correo fue generado desde PHP puro.\n"
. "Sendmail se encarga de procesarlo.\n\n"
. "Saludos desde Debian 13.";
$cabeceras = [
"From" => $remitente,
"Reply-To" => $remitente,
"Content-Type" => "text/plain; charset=UTF-8"
];
$resultado = mail(
$destinatario,
$asunto,
$mensaje,
$cabeceras
);
if ($resultado) {
echo "PHP entregó el mensaje al sistema local de correo.\n";
} else {
echo "No se pudo entregar el mensaje a Sendmail.\n";
}
Debemos reemplazar las direcciones por valores reales.
Para nuestras primeras pruebas utilizaremos como remitente la cuenta autorizada por el SMTP.
5.1. Explicación del código
Apertura de PHP
<?php
Indica el comienzo del código PHP.
En archivos que contienen exclusivamente PHP no necesitamos agregar la etiqueta de cierre ?>.
Variables
$destinatario = "[email protected]";
En PHP, las variables comienzan con $.
El signo = asigna un valor.
El punto y coma ; finaliza la instrucción.
Concatenación
$mensaje = "Hola.\n"
. "Saludos desde Debian.";
El operador . permite unir cadenas de texto.
\n representa un salto de línea.
Arreglos
$cabeceras = [
"From" => $remitente,
"Reply-To" => $remitente
];
Los corchetes [] crean un arreglo.
El operador => relaciona una clave con su valor.
En este caso utilizamos un arreglo asociativo para organizar las cabeceras.
Condición
if ($resultado) {
Comprueba si el valor de $resultado es verdadero.
if inicia la condición.
else permite ejecutar otra instrucción cuando la condición no se cumple.
echo
echo "Mensaje enviado";
Muestra texto en la salida del programa.
6. Ejecutar PHP desde la terminal
Guardamos el archivo.
Después ejecutamos:
php prueba-mail.php
Si PHP entrega correctamente el mensaje al sistema local, obtendremos:
PHP entregó el mensaje al sistema local de correo.
Ahora comprobamos los registros:
sudo journalctl -u sendmail -n 50 --no-pager
También podemos consultar:
sudo sendmail -bp
Si el mensaje está pendiente, debemos revisar la razón.
Importante: no debemos interpretar el mensaje mostrado por PHP como una confirmación de recepción final.
7. Crear un proyecto web completo
Ahora construiremos un formulario de contacto.
Utilizaremos esta estructura:
php-sendmail/
│
├── index.php
├── procesar.php
└── styles.css
Cada archivo tendrá una responsabilidad.
index.php mostrará el formulario y generará un token de seguridad.
procesar.php recibirá los datos, los validará y enviará el mensaje.
styles.css contendrá los estilos visuales.
No utilizaremos JavaScript porque PHP y HTML son suficientes para comprender el funcionamiento de este proyecto.
8. Crear el formulario HTML
Abrimos:
nano index.php
Agregamos:
<?php
declare(strict_types=1);
session_start();
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
$token = $_SESSION["csrf_token"];
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>Formulario de contacto | OPERSLINUX</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="contenedor">
<section class="tarjeta">
<h1>Formulario de contacto</h1>
<p>
Envíanos un mensaje utilizando
PHP puro y Sendmail.
</p>
<form action="procesar.php" method="POST">
<input
type="hidden"
name="csrf_token"
value="<?= htmlspecialchars(
$token,
ENT_QUOTES,
'UTF-8'
) ?>"
>
<div class="campo">
<label for="nombre">
Nombre
</label>
<input
type="text"
id="nombre"
name="nombre"
maxlength="100"
autocomplete="name"
required
>
</div>
<div class="campo">
<label for="correo">
Correo electrónico
</label>
<input
type="email"
id="correo"
name="correo"
maxlength="254"
autocomplete="email"
required
>
</div>
<div class="campo">
<label for="mensaje">
Mensaje
</label>
<textarea
id="mensaje"
name="mensaje"
rows="6"
maxlength="3000"
required
></textarea>
</div>
<div class="trampa" aria-hidden="true">
<label for="sitio_web">
Deja este campo vacío
</label>
<input
type="text"
id="sitio_web"
name="sitio_web"
tabindex="-1"
autocomplete="off"
>
</div>
<button type="submit">
Enviar mensaje
</button>
</form>
</section>
</main>
</body>
</html>
8.1. ¿Qué hace session_start()?
session_start();
Inicia o recupera una sesión de PHP.
Las sesiones permiten conservar determinados datos entre solicitudes HTTP.
En nuestro caso utilizaremos la sesión para almacenar un token de protección CSRF.
8.2. ¿Qué hace random_bytes()?
random_bytes(32)
Genera 32 bytes aleatorios mediante una fuente criptográficamente segura.
Después utilizamos:
bin2hex(...)
para convertirlos a una representación hexadecimal.
El resultado será un token difícil de predecir.
8.3. ¿Qué significa method=”POST”?
<form action="procesar.php" method="POST">
form crea el formulario.
action indica el archivo que recibirá los datos.
method="POST" solicita enviar los datos mediante el método HTTP POST.
Esto evita colocar los campos del formulario en la URL como ocurriría normalmente con GET.
Sin embargo, POST no cifra la información. En un sitio público debemos utilizar HTTPS.
8.4. ¿Para qué sirve name?
<input name="correo">
El atributo name identifica el dato enviado al servidor.
PHP podrá acceder a él mediante:
$_POST["correo"]
El atributo id, en cambio, permite relacionar el campo con su etiqueta y seleccionarlo mediante CSS o JavaScript.
8.5. ¿Qué significa required?
required
Indica al navegador que el campo es obligatorio.
Esta validación mejora la experiencia del usuario, pero no sustituye la validación de PHP.
Un atacante puede enviar solicitudes directamente al servidor sin utilizar nuestro formulario.
8.6. ¿Qué es el campo oculto sitio_web?
Es una técnica conocida como honeypot.
El campo está oculto visualmente y los usuarios normales no deberían completarlo.
Algunos bots rellenan automáticamente todos los campos que encuentran.
Si recibimos un valor en sitio_web, podemos rechazar el envío.
No es una protección suficiente por sí sola, pero añade una barrera sencilla contra automatizaciones básicas.
9. Agregar estilos CSS
Abrimos:
nano styles.css
Escribimos:
:root {
--fondo: #080d12;
--tarjeta: #111b23;
--borde: #294234;
--texto: #e7f5ec;
--secundario: #9db0a5;
--verde: #00ff77;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Arial, sans-serif;
background:
radial-gradient(
circle at top,
#153326,
var(--fondo) 65%
);
color: var(--texto);
}
.contenedor {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
.tarjeta {
width: 100%;
max-width: 540px;
padding: 32px;
background: var(--tarjeta);
border: 1px solid var(--borde);
border-radius: 16px;
}
h1 {
margin-top: 0;
color: var(--verde);
}
p {
color: var(--secundario);
line-height: 1.6;
}
.campo {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
input,
textarea {
width: 100%;
padding: 12px;
background: #091218;
color: var(--texto);
border: 1px solid #355245;
border-radius: 8px;
font: inherit;
}
textarea {
resize: vertical;
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 2px solid var(--verde);
outline-offset: 3px;
}
button {
width: 100%;
padding: 14px;
border: 0;
border-radius: 8px;
background: var(--verde);
color: #07110b;
font: inherit;
font-weight: bold;
cursor: pointer;
}
button:hover {
background: #7affb0;
}
.trampa {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 600px) {
.tarjeta {
padding: 22px;
}
}
¿Qué estamos utilizando?
:root permite definir variables CSS globales.
--fondo, --texto y otras variables almacenan colores reutilizables.
var() recupera el valor de una variable CSS.
display: flex activa Flexbox.
justify-content: center centra horizontalmente los elementos en este diseño.
align-items: center los centra en el eje transversal.
max-width limita el ancho máximo de la tarjeta.
@media permite aplicar reglas específicas según las características de la pantalla.
De esta forma tendremos un formulario adaptable a computadoras y teléfonos.
10. Crear el archivo procesar.php
Este será el archivo más importante del proyecto.
Aquí comprobaremos los datos antes de permitir que PHP intente enviar un correo.
Abrimos:
nano procesar.php
Agregamos:
<?php
declare(strict_types=1);
session_start();
/*
|--------------------------------------------------------------------------
| CONFIGURACIÓN
|--------------------------------------------------------------------------
*/
$destinatario = "[email protected]";
$remitente = "[email protected]";
$asunto = "Nuevo mensaje desde el formulario";
/*
|--------------------------------------------------------------------------
| RESPUESTA
|--------------------------------------------------------------------------
*/
function responder(string $mensaje, int $codigo = 200): never
{
http_response_code($codigo);
header("Content-Type: text/html; charset=UTF-8");
$texto = htmlspecialchars(
$mensaje,
ENT_QUOTES | ENT_SUBSTITUTE,
"UTF-8"
);
echo '<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>Resultado del formulario</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="contenedor">
<section class="tarjeta">
<h1>Resultado del envío</h1>
<p>' . $texto . '</p>
<a href="index.php">Volver al formulario</a>
</section>
</main>
</body>
</html>';
exit;
}
/*
|--------------------------------------------------------------------------
| COMPROBAR MÉTODO HTTP
|--------------------------------------------------------------------------
*/
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
responder("Método no permitido.", 405);
}
/*
|--------------------------------------------------------------------------
| VALIDAR TOKEN CSRF
|--------------------------------------------------------------------------
*/
$tokenSesion = $_SESSION["csrf_token"] ?? null;
$tokenFormulario = $_POST["csrf_token"] ?? null;
if (
!is_string($tokenSesion) ||
!is_string($tokenFormulario) ||
!hash_equals($tokenSesion, $tokenFormulario)
) {
responder("La solicitud no es válida.", 403);
}
/*
|--------------------------------------------------------------------------
| CONTROL BÁSICO DE FRECUENCIA
|--------------------------------------------------------------------------
*/
$ahora = time();
$ultimoEnvio = $_SESSION["ultimo_envio"] ?? 0;
if ($ahora - (int) $ultimoEnvio < 60) {
responder(
"Espera un minuto antes de enviar otro mensaje.",
429
);
}
/*
|--------------------------------------------------------------------------
| OBTENER LOS CAMPOS
|--------------------------------------------------------------------------
*/
$nombre = $_POST["nombre"] ?? "";
$correo = $_POST["correo"] ?? "";
$mensaje = $_POST["mensaje"] ?? "";
$sitioWeb = $_POST["sitio_web"] ?? "";
/*
|--------------------------------------------------------------------------
| COMPROBAR TIPOS DE DATOS
|--------------------------------------------------------------------------
*/
if (
!is_string($nombre) ||
!is_string($correo) ||
!is_string($mensaje) ||
!is_string($sitioWeb)
) {
responder("Los datos recibidos no son válidos.", 400);
}
/*
|--------------------------------------------------------------------------
| HONEYPOT
|--------------------------------------------------------------------------
*/
if ($sitioWeb !== "") {
responder("No se pudo procesar la solicitud.", 400);
}
/*
|--------------------------------------------------------------------------
| NORMALIZAR DATOS
|--------------------------------------------------------------------------
*/
$nombre = trim($nombre);
$correo = trim($correo);
$mensaje = trim($mensaje);
/*
|--------------------------------------------------------------------------
| VALIDAR CAMPOS OBLIGATORIOS
|--------------------------------------------------------------------------
*/
if ($nombre === "" || $correo === "" || $mensaje === "") {
responder("Todos los campos son obligatorios.", 400);
}
/*
|--------------------------------------------------------------------------
| VALIDAR LONGITUD
|--------------------------------------------------------------------------
*/
if (
mb_strlen($nombre, "UTF-8") > 100 ||
strlen($correo) > 254 ||
mb_strlen($mensaje, "UTF-8") > 3000
) {
responder("Uno de los campos supera el límite permitido.", 400);
}
/*
|--------------------------------------------------------------------------
| VALIDAR CORREO
|--------------------------------------------------------------------------
*/
if (!filter_var($correo, FILTER_VALIDATE_EMAIL)) {
responder("La dirección de correo no es válida.", 400);
}
/*
|--------------------------------------------------------------------------
| EVITAR SALTOS DE LÍNEA EN CAMPOS DE CABECERA
|--------------------------------------------------------------------------
*/
if (
preg_match('/[\r\n]/', $nombre) ||
preg_match('/[\r\n]/', $correo)
) {
responder("Se encontraron caracteres no permitidos.", 400);
}
/*
|--------------------------------------------------------------------------
| PREPARAR EL MENSAJE
|--------------------------------------------------------------------------
*/
$cuerpo = "Nuevo mensaje recibido desde el formulario.\n\n";
$cuerpo .= "Nombre: " . $nombre . "\n";
$cuerpo .= "Correo: " . $correo . "\n\n";
$cuerpo .= "Mensaje:\n" . $mensaje . "\n";
/*
|--------------------------------------------------------------------------
| CABECERAS
|--------------------------------------------------------------------------
*/
$cabeceras = [
"From" => $remitente,
"Reply-To" => $correo,
"MIME-Version" => "1.0",
"Content-Type" => "text/plain; charset=UTF-8"
];
/*
|--------------------------------------------------------------------------
| ENVIAR
|--------------------------------------------------------------------------
*/
$resultado = mail(
$destinatario,
$asunto,
$cuerpo,
$cabeceras
);
/*
|--------------------------------------------------------------------------
| COMPROBAR RESULTADO
|--------------------------------------------------------------------------
*/
if ($resultado) {
$_SESSION["ultimo_envio"] = $ahora;
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
responder(
"Tu mensaje fue aceptado para su procesamiento. Gracias por escribirnos."
);
}
error_log("El formulario no pudo entregar el mensaje al MTA local.");
responder(
"No fue posible procesar el mensaje en este momento.",
500
);
Debemos reemplazar:
[email protected] por la dirección donde recibiremos los mensajes.
[email protected] por el remitente autorizado en Sendmail.
Si utilizamos HostGator, podremos usar una dirección de nuestro dominio autorizada por el proveedor.
11. Comprender la validación del formulario
Analizaremos las instrucciones más importantes.
11.1. Comprobar el método HTTP
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
responder("Método no permitido.", 405);
}
$_SERVER contiene información proporcionada por el entorno del servidor.
REQUEST_METHOD indica el método HTTP utilizado.
!== significa que los valores no son idénticos.
Si alguien intenta abrir directamente procesar.php mediante una solicitud GET, el código responderá con un error.
11.2. Obtener datos con $_POST
$nombre = $_POST["nombre"] ?? "";
$_POST contiene los datos recibidos mediante formularios POST compatibles.
?? es el operador de fusión de null.
Permite utilizar una cadena vacía cuando el valor no existe o es null.
11.3. Validar el tipo de dato
is_string($nombre)
Comprueba que el valor sea una cadena.
Esto es importante porque un atacante podría enviar un arreglo en lugar de un texto.
Por ejemplo, mediante un campo llamado nombre[].
Si nuestro código no comprueba el tipo, algunas funciones podrían producir errores.
11.4. Eliminar espacios innecesarios
trim($nombre)
Elimina espacios y determinados caracteres en blanco al principio y al final.
Por ejemplo:
" Andrés "
se convierte en:
"Andrés"
11.5. Validar direcciones de correo
filter_var($correo, FILTER_VALIDATE_EMAIL)
Comprueba que la dirección tenga un formato de correo aceptado por el validador de PHP.
Esto no demuestra que el buzón exista ni que pueda recibir mensajes.
Tampoco garantiza que el correo pertenezca a la persona que lo escribió.
11.6. Validar longitudes
mb_strlen($mensaje, "UTF-8")
Cuenta caracteres multibyte.
Resulta útil para textos que pueden contener acentos y otros caracteres Unicode.
La función strlen(), en cambio, cuenta bytes.
11.7. Evitar inyección de cabeceras
preg_match('/[\r\n]/', $correo)
Busca caracteres de retorno de carro o salto de línea.
Estos caracteres no deben aparecer en los valores que utilizaremos para construir determinadas cabeceras.
La inyección de cabeceras ocurre cuando una entrada no confiable consigue introducir cabeceras adicionales en un mensaje.
Por ejemplo, podría intentar agregar destinatarios no autorizados.
Por eso no debemos insertar directamente texto del usuario en From:, To:, Subject: u otras cabeceras sin validarlo.
En nuestro proyecto, el destinatario y el remitente son valores fijos definidos por el administrador.
El correo proporcionado por el visitante solamente se utiliza como Reply-To después de validarlo.
12. ¿Qué es un token CSRF?
CSRF significa Cross-Site Request Forgery.
Es un tipo de ataque en el que se intenta provocar que el navegador de una persona realice una solicitud no deseada.
Para reducir este riesgo utilizamos un token asociado a la sesión.
En index.php generamos:
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
Después lo incluimos en un campo oculto.
Cuando el formulario llega a procesar.php, comparamos el token recibido con el almacenado en la sesión:
hash_equals($tokenSesion, $tokenFormulario)
hash_equals() compara cadenas de una manera diseñada para reducir filtraciones de información por diferencias de tiempo.
Si los valores no coinciden, rechazamos la solicitud.
Importante: CSRF no es un mecanismo antispam completo. Un bot puede visitar el formulario, obtener un token válido y enviar mensajes.
Por eso debemos combinarlo con otras protecciones.
13. Controlar los envíos repetidos
En nuestro ejemplo utilizamos:
$ahora = time();
time() devuelve el tiempo Unix actual en segundos.
Después comprobamos:
if ($ahora - (int) $ultimoEnvio < 60)
Esto permite impedir otro envío inmediato desde la misma sesión.
El código HTTP:
429
significa Too Many Requests.
Sin embargo, este control tiene una limitación: un visitante puede crear nuevas sesiones o utilizar distintos clientes para evitarlo.
Por lo tanto, no es suficiente para proteger un formulario público de alto tráfico.
En producción conviene aplicar límites del lado del servidor mediante una base de datos, Redis, reglas del servidor web o mecanismos equivalentes.
También podemos utilizar protección antispam adicional y límites por dirección IP, teniendo cuidado con los usuarios que comparten una misma red.
14. Ejecutar nuestro proyecto con el servidor integrado de PHP
Para probar el formulario no necesitamos configurar Apache inmediatamente.
PHP incluye un servidor de desarrollo.
Desde el directorio del proyecto ejecutamos:
php -S 127.0.0.1:8000
Explicación
php ejecuta PHP.
-S inicia el servidor web integrado.
127.0.0.1 es la dirección de loopback del equipo.
8000 es el puerto utilizado.
Ahora abrimos nuestro navegador y visitamos:
http://127.0.0.1:8000
Veremos el formulario.
Completamos los campos y presionamos Enviar mensaje.
PHP procesará la solicitud y, si los datos son válidos, intentará entregar el mensaje a Sendmail.
Para detener el servidor presionamos Ctrl + C.
Advertencia: el servidor integrado de PHP está diseñado para desarrollo y pruebas. No debemos utilizarlo como servidor público de producción.
15. Comprobar el resultado en Sendmail
Después de enviar el formulario, abrimos otra terminal.
Ejecutamos:
sudo journalctl -u sendmail -n 50 --no-pager
Buscamos el mensaje reciente.
Si utilizamos Gmail, podemos encontrar información similar a:
STARTTLS=client,
relay=smtp.gmail.com.,
version=TLSv1.3,
verify=OK
Y posteriormente:
dsn=2.0.0, stat=Sent
Esto indica que Sendmail registró una entrega satisfactoria al siguiente servidor.
También podemos consultar mensajes pendientes:
sudo sendmail -bp
Y la cola de envío local:
sudo sendmail -Ac -bp
Si aparece:
stat=Deferred
debemos revisar el motivo.
Puede existir un problema de conectividad, autenticación o configuración.
16. Enviar correos HTML desde PHP
Nuestro formulario ya envía mensajes de texto plano.
Ahora aprenderemos a construir un mensaje HTML.
Para ello utilizaremos cabeceras MIME.
Podemos crear un archivo de prueba:
nano prueba-html.php
Agregamos:
<?php
declare(strict_types=1);
$destinatario = "[email protected]";
$remitente = "[email protected]";
$asunto = "Correo HTML desde PHP";
$mensaje = '
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Notificación</title>
</head>
<body style="font-family:Arial,sans-serif;background:#f5f5f5;padding:20px;">
<div style="max-width:600px;margin:auto;background:#ffffff;padding:25px;">
<h1 style="color:#087c45;">
Mensaje desde PHP
</h1>
<p>
Este correo fue generado utilizando
<strong>PHP puro y Sendmail</strong>.
</p>
<p>
No necesitamos una biblioteca SMTP
para este ejemplo.
</p>
<hr>
<small>
Notificación de prueba.
</small>
</div>
</body>
</html>';
$cabeceras = [
"From" => $remitente,
"MIME-Version" => "1.0",
"Content-Type" => "text/html; charset=UTF-8"
];
$resultado = mail(
$destinatario,
$asunto,
$mensaje,
$cabeceras
);
echo $resultado
? "Mensaje HTML aceptado por el sistema local.\n"
: "Error al entregar el mensaje.\n";
Ejecutamos:
php prueba-html.php
16.1. ¿Qué significa text/html?
"Content-Type" => "text/html; charset=UTF-8"
Indica que el cuerpo del mensaje contiene HTML.
El destinatario podrá visualizar el contenido como una página simplificada, dependiendo de las capacidades y restricciones de su cliente de correo.
16.2. ¿Por qué utilizamos estilos inline?
Muchos clientes de correo restringen o modifican el CSS.
Por eso utilizamos estilos directamente en las etiquetas:
<h1 style="color:#087c45;">
Esto suele mejorar la compatibilidad.
Sin embargo, no todos los clientes de correo muestran HTML de la misma manera.
16.3. ¿Qué pasa con las imágenes?
Las imágenes remotas pueden estar bloqueadas por el cliente de correo.
Además, no debemos confiar en que JavaScript, formularios interactivos o CSS avanzado funcionen dentro de un correo.
Para mensajes profesionales conviene utilizar HTML sencillo y proporcionar una alternativa de texto plano mediante MIME multipart/alternative.
17. Convertir los datos del formulario a HTML de forma segura
Si queremos enviar el nombre y el mensaje del visitante dentro de un correo HTML, debemos escapar el contenido.
Por ejemplo:
$nombreSeguro = htmlspecialchars(
$nombre,
ENT_QUOTES | ENT_SUBSTITUTE,
"UTF-8"
);
$mensajeSeguro = htmlspecialchars(
$mensaje,
ENT_QUOTES | ENT_SUBSTITUTE,
"UTF-8"
);
¿Qué hace htmlspecialchars()?
Convierte caracteres especiales a entidades HTML.
Por ejemplo:
<script>alert("Hola")</script>
se convierte en texto escapado, evitando que sea interpretado como una etiqueta HTML activa.
Después podemos construir:
$cuerpoHtml = '
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Nuevo mensaje de contacto</h1>
<p><strong>Nombre:</strong> '
. $nombreSeguro .
'</p>
<p><strong>Mensaje:</strong></p>
<p>'
. nl2br($mensajeSeguro, false) .
'</p>
</body>
</html>';
¿Qué hace nl2br()?
Convierte los saltos de línea del texto en etiquetas HTML de salto de línea.
Así podemos conservar visualmente los párrafos escritos por el visitante.
Importante: primero escapamos el contenido mediante htmlspecialchars() y después aplicamos nl2br().
De esta forma no interpretamos el texto del visitante como código HTML.
Este fragmento puede sustituir la construcción de $cuerpo en procesar.php, siempre que también cambiemos la cabecera Content-Type a text/html; charset=UTF-8.
18. Comprender el remitente y Reply-To
En nuestro formulario utilizamos:
$cabeceras = [
"From" => $remitente,
"Reply-To" => $correo
];
Esto es intencional.
From contiene la dirección autorizada desde la que enviamos el mensaje.
Reply-To contiene la dirección proporcionada por el visitante.
¿Por qué no colocar directamente el correo del visitante como remitente?
Porque nuestro servidor SMTP puede rechazar remitentes que no pertenezcan a la cuenta autenticada o que no estén autorizados.
Además, enviar mensajes utilizando arbitrariamente el dominio de otra persona puede generar problemas de autenticación y entregabilidad relacionados con SPF, DKIM y DMARC.
La estructura recomendada para nuestro formulario es:
From: [email protected]
Reply-To: [email protected]
To: [email protected]
Así, cuando el administrador responda al correo, su cliente podrá utilizar la dirección del visitante.
19. Configurar el remitente del sobre SMTP
Existe una diferencia entre la cabecera From y el remitente del sobre SMTP.
La cabecera From identifica al remitente visible.
El remitente del sobre se utiliza durante el transporte y puede influir en los mensajes de rebote.
En PHP, mail() permite proporcionar parámetros adicionales al programa de envío.
Por ejemplo:
$resultado = mail(
$destinatario,
$asunto,
$mensaje,
$cabeceras,
"[email protected]"
);
El parámetro:
[email protected]
solicita establecer esa dirección como remitente del sobre.
Debemos utilizar únicamente direcciones autorizadas por nuestro servidor SMTP.
Advertencia importante: nunca debemos construir el quinto parámetro de mail() utilizando directamente datos proporcionados por el visitante.
Ese parámetro se transmite al mecanismo local de envío y debe tratarse como una configuración sensible.
Además, Sendmail puede restringir el uso de -f según el usuario que ejecute PHP y su configuración de remitentes confiables.
Para nuestro formulario hemos omitido inicialmente este parámetro y mantenido un remitente fijo en las cabeceras.
Si el proveedor SMTP exige una identidad concreta del sobre, debemos configurarla de forma controlada y comprobar los registros.
20. Manejo de errores en PHP
Es importante comprender que los errores pueden producirse en diferentes etapas.
20.1. Error de validación
Ocurre cuando el usuario proporciona información incompleta o inválida.
Por ejemplo:
if ($mensaje === "") {
responder("El mensaje es obligatorio.", 400);
}
El código HTTP 400 indica una solicitud incorrecta.
20.2. Error de seguridad
Ocurre cuando el token CSRF no coincide.
En nuestro ejemplo utilizamos:
responder("La solicitud no es válida.", 403);
El código 403 indica que la solicitud fue rechazada.
20.3. Error al entregar el mensaje
Ocurre cuando mail() devuelve false.
Nuestro código utiliza:
error_log(
"El formulario no pudo entregar el mensaje al MTA local."
);
error_log() registra un mensaje en el destino de errores configurado para PHP.
No debemos mostrar al visitante rutas internas, contraseñas ni detalles sensibles del servidor.
20.4. Error posterior de Sendmail
También puede ocurrir que mail() devuelva true, pero Sendmail no consiga entregar el correo al servidor externo.
En ese caso debemos consultar:
sudo journalctl -u sendmail -n 50 --no-pager
y:
sudo sendmail -bp
El problema puede encontrarse en la conexión SMTP y no en PHP.
21. Diferenciar PHP, Sendmail y SMTP
Aunque trabajan juntos, cumplen funciones diferentes.
| Tecnología | Función |
|---|---|
| HTML | Construye el formulario visible. |
| CSS | Define su presentación visual. |
| PHP | Recibe, valida y procesa los datos. |
mail() | Entrega el mensaje al mecanismo local de correo. |
| Sendmail | Procesa, enruta y retransmite mensajes. |
| SMTP | Protocolo utilizado para transportar mensajes entre sistemas. |
| STARTTLS | Permite negociar cifrado TLS en una conexión compatible. |
Esta separación resulta importante porque nos ayuda a identificar dónde ocurre un error.
Si el formulario no envía datos, investigaremos HTML y PHP.
Si PHP no puede ejecutar el mecanismo local de correo, revisaremos la configuración de mail() y los permisos.
Si Sendmail acepta el mensaje pero no consigue retransmitirlo, revisaremos el servidor SMTP, la autenticación, TLS y los registros.
22. ¿Cómo utilizar este proyecto con Apache?
Hasta ahora utilizamos el servidor integrado de PHP.
Para alojar el formulario en un servidor Apache necesitaremos instalar y configurar el servidor web y un mecanismo para ejecutar PHP.
En una instalación sencilla de Debian podemos utilizar:
sudo apt install apache2 libapache2-mod-php php-mbstring
Después comprobamos:
sudo systemctl status apache2 --no-pager
Si Apache está activo, podremos preparar un directorio dentro de su raíz documental configurada.
En una instalación predeterminada suele utilizarse:
/var/www/html
Sin embargo, es preferible configurar un VirtualHost propio para cada proyecto.
Debemos asegurarnos de que los archivos tengan permisos adecuados y de que el servidor web no permita modificar el código desde solicitudes públicas.
Importante: Apache y PHP pueden ejecutar el formulario bajo un usuario como www-data, mientras que nuestras pruebas de terminal utilizan nuestro usuario personal.
Por ello, las diferencias de permisos y configuración pueden provocar que el formulario funcione desde la terminal, pero falle desde el navegador.
También debemos comprobar el valor de sendmail_path utilizado por PHP en el entorno web.
No debemos publicar una página phpinfo() accesible a cualquier visitante, porque podría revelar información interna del servidor.
23. Seguridad antes de publicar el formulario
Nuestro proyecto incluye varias protecciones básicas, pero un formulario público requiere medidas adicionales.
23.1. Utilizar HTTPS
Los datos del visitante deben transmitirse mediante una conexión HTTPS válida.
No debemos publicar un formulario de contacto utilizando HTTP sin cifrado.
23.2. Mantener PHP actualizado
Debemos instalar las actualizaciones de seguridad correspondientes.
23.3. Limitar el tamaño de las solicitudes
Además de validar la longitud de los campos, podemos configurar límites como post_max_size en PHP y restricciones de tamaño en el servidor web.
23.4. Aplicar límites reales de frecuencia
El control por sesión que implementamos es una protección básica para aprender.
En producción conviene utilizar un sistema persistente de límites, especialmente si el formulario está disponible públicamente.
23.5. No permitir destinatarios arbitrarios
El destinatario debe estar definido por el administrador.
No debemos permitir que un visitante indique libremente a quién enviar correos, ya que nuestro formulario podría convertirse en una herramienta de spam.
23.6. No incluir contraseñas SMTP en el código
Sendmail ya administra las credenciales.
No necesitamos colocarlas en los archivos PHP.
23.7. Escapar los datos
Cuando mostramos información proporcionada por visitantes en HTML, debemos utilizar mecanismos como htmlspecialchars().
23.8. Proteger las sesiones
En producción debemos configurar cookies de sesión con atributos apropiados, como Secure, HttpOnly y una política SameSite adecuada.
El atributo Secure requiere HTTPS.
23.9. Evitar mensajes de error sensibles
Los visitantes deben recibir mensajes comprensibles, sin conocer rutas internas, contraseñas ni configuraciones del servidor.
Los detalles técnicos deben registrarse de forma segura para que el administrador pueda investigarlos.
24. Problemas frecuentes
Problema: mail() devuelve false
Posibles causas:
- Sendmail no está instalado.
- PHP no encuentra el ejecutable.
- La configuración
sendmail_pathes incorrecta. - Existen restricciones de permisos.
- El mecanismo local de correo falla.
Comprobamos:
php -i | grep -i sendmail_path
Y:
command -v sendmail
Problema: PHP devuelve true, pero el correo no llega
Debemos comprobar los registros de Sendmail.
sudo journalctl -u sendmail -n 50 --no-pager
También revisamos la cola:
sudo sendmail -bp
Puede existir un error de autenticación, conectividad o política del proveedor.
Problema: el correo llega como spam
Debemos revisar:
- Dirección del remitente.
- Autenticación del dominio.
- SPF.
- DKIM.
- DMARC.
- Contenido del mensaje.
- Reputación del servidor SMTP.
No existe una configuración que garantice llegar siempre a la bandeja principal.
Problema: funciona desde terminal, pero no desde Apache
Debemos comprobar la configuración PHP utilizada por el servidor web, el usuario que ejecuta PHP y sus permisos.
Problema: aparece el código PHP en el navegador
Esto indica que el servidor web no está interpretando PHP correctamente.
Debemos revisar la instalación y configuración del mecanismo PHP del servidor.
No debemos dejar publicado el código fuente de la aplicación.
25. Práctica final
Para terminar, realizaremos una comprobación completa del proyecto.
Paso 1. Comprobar Sendmail.
sudo systemctl status sendmail --no-pager
Paso 2. Comprobar PHP.
php -v
Paso 3. Comprobar la configuración de correo.
php -i | grep -i sendmail_path
Paso 4. Revisar la sintaxis de los archivos PHP.
php -l index.php
php -l procesar.php
El parámetro -l solicita comprobar la sintaxis sin ejecutar el archivo.
Paso 5. Iniciar el servidor de desarrollo.
php -S 127.0.0.1:8000
Paso 6. Abrir el formulario en el navegador.
http://127.0.0.1:8000
Paso 7. Completar el formulario con datos de prueba.
Paso 8. Comprobar el mensaje de respuesta.
Paso 9. Consultar los registros.
sudo journalctl -u sendmail -n 50 --no-pager
Paso 10. Comprobar el buzón destinatario.
Si Sendmail muestra que el servidor SMTP aceptó el mensaje, habremos completado la prueba de retransmisión.
Finalmente debemos verificar que el mensaje haya llegado al buzón esperado.
Conclusión
En esta sexta parte aprendimos a integrar PHP puro con Sendmail en Debian 13.
Comenzamos enviando un mensaje desde la terminal mediante la función mail() y después desarrollamos un formulario de contacto con HTML, CSS y PHP.
También aprendimos a validar los datos, utilizar sesiones y tokens CSRF, establecer límites básicos de frecuencia, enviar mensajes HTML y consultar los registros cuando ocurre un error.
Una de las ideas más importantes es que PHP no necesita conectarse directamente a Gmail o HostGator cuando ya tenemos Sendmail configurado como agente de transporte de correo.
Esta arquitectura permite separar la lógica de nuestra aplicación de la configuración SMTP.
Sin embargo, también debemos recordar que una respuesta satisfactoria de mail() no garantiza la entrega final del mensaje.
Para administrar correctamente una aplicación debemos combinar la validación del formulario, el manejo de errores y la supervisión de Sendmail.
Final de la serie: Sendmail en Debian
Con este tutorial concluimos nuestra serie de seis entregas:
- Instalar y configurar Sendmail en Debian 13 desde cero.
- Configurar Sendmail con SMTP de HostGator mediante STARTTLS.
- Configurar Sendmail para enviar correos con Gmail SMTP.
- Enviar correos y solucionar errores de Sendmail en Debian.
- Enviar correos automáticos con Bash y Sendmail.
- Enviar correos desde PHP puro utilizando Sendmail.
A lo largo de estas publicaciones aprendimos a trabajar con servidores SMTP, autenticación, certificados TLS, herramientas de terminal, scripts Bash, tareas programadas y aplicaciones web.
Estos conocimientos pueden servir como base para desarrollar formularios de contacto, notificaciones administrativas, avisos automáticos y otros proyectos relacionados con Linux y programación.
OPERSLINUX — Aprende Linux, servidores y programación mediante la práctica.



