Tema
Enviar correos con PHPMailer
Formularios de contacto, confirmación de pedidos, recuperación de contraseña, avisos al administrador... Enviar correos es un requisito en casi cualquier proyecto para clientes. Aprenderás a hacerlo de forma confiable con PHPMailer y SMTP, para que tus correos lleguen a la bandeja de entrada y no a spam.
Concepto
PHP trae una función mail(), pero en la práctica casi no sirve:
- En Windows con XAMPP no funciona sin configuración extra.
- En hosting compartido, los correos enviados con
mail()suelen ir a spam porque no están autenticados. - No soporta fácilmente adjuntos, HTML ni autenticación.
La solución profesional es enviar a través de un servidor SMTP (el "cartero" de los correos), autenticándote con usuario y contraseña, igual que lo hace Outlook o Thunderbird. PHPMailer es la librería más usada para eso.
Opciones de SMTP:
| Opción | Uso típico |
|---|---|
| Correo del hosting (cPanel) | contacto@dominiodelcliente.com. Lo más común en proyectos pequeños. |
| Gmail / Google Workspace | Pruebas o negocios que ya usan Google. Requiere "contraseña de aplicación". |
| Servicios transaccionales (Brevo, Mailgun, Amazon SES, Resend, Postmark) | Volumen alto, mejores tasas de entrega, estadísticas. |
| Mailpit / Mailtrap | Desarrollo: atrapan los correos para que los veas sin enviarlos a nadie. |
Instalación
bash
composer require phpmailer/phpmailerSi no puedes usar Composer, descarga el ZIP desde GitHub (PHPMailer/PHPMailer) y requiere manualmente los archivos src/PHPMailer.php, src/SMTP.php y src/Exception.php.
Configuración con .env
Nunca escribas la contraseña del correo en el código. Usa variables de entorno (ver Composer y Gestión de secretos):
bash
composer require vlucas/phpdotenvenv
# .env
MAIL_HOST=mail.midominio.com
MAIL_PORT=465
MAIL_USER=contacto@midominio.com
MAIL_PASS=contraseña-segura
MAIL_FROM=contacto@midominio.com
MAIL_FROM_NAME="Florería Lupita"
MAIL_ADMIN=duena@midominio.comEjemplo: tu primer correo
php
<?php
// enviar-prueba.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
Dotenv\Dotenv::createImmutable(__DIR__)->load();
// true = lanzar excepciones en caso de error
$mail = new PHPMailer(true);
try {
// --- Servidor SMTP ---
$mail->isSMTP();
$mail->Host = $_ENV['MAIL_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USER'];
$mail->Password = $_ENV['MAIL_PASS'];
// Puerto 465 = SSL implícito (SMTPS). Puerto 587 = STARTTLS.
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = (int) $_ENV['MAIL_PORT'];
$mail->CharSet = PHPMailer::CHARSET_UTF8; // acentos y ñ correctos
// Descomenta para ver la conversación con el servidor si algo falla
// $mail->SMTPDebug = SMTP::DEBUG_SERVER;
// --- Remitente y destinatarios ---
$mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME']);
$mail->addAddress('cliente@ejemplo.com', 'Ana López');
$mail->addReplyTo('ventas@midominio.com', 'Ventas');
// $mail->addCC('copia@ejemplo.com');
// $mail->addBCC('oculto@ejemplo.com');
// --- Contenido ---
$mail->isHTML(true);
$mail->Subject = '¡Bienvenida a nuestra tienda!';
$mail->Body = '<h1>Hola, Ana</h1><p>Gracias por registrarte. Tu cupón es <strong>BIENVENIDA10</strong>.</p>';
// Versión en texto plano para clientes de correo sin HTML (mejora la entrega)
$mail->AltBody = "Hola, Ana\nGracias por registrarte. Tu cupón es BIENVENIDA10.";
$mail->send();
echo 'Correo enviado';
} catch (Exception $e) {
// ErrorInfo tiene el detalle técnico: regístralo, no lo muestres al usuario
error_log('Error de correo: ' . $mail->ErrorInfo);
echo 'No se pudo enviar el correo.';
}Configuración según proveedor
| Proveedor | Host | Puerto | Seguridad |
|---|---|---|---|
| cPanel típico | mail.tudominio.com | 465 | ENCRYPTION_SMTPS |
| Gmail | smtp.gmail.com | 587 | ENCRYPTION_STARTTLS |
| Outlook / Microsoft 365 | smtp.office365.com | 587 | ENCRYPTION_STARTTLS |
| Brevo | smtp-relay.brevo.com | 587 | ENCRYPTION_STARTTLS |
| Mailpit (local) | localhost | 1025 | sin cifrado, SMTPAuth = false |
Gmail
Gmail no acepta tu contraseña normal. Activa la verificación en dos pasos y genera una contraseña de aplicación en tu cuenta de Google (Seguridad > Contraseñas de aplicaciones). Úsala como MAIL_PASS.
Una clase reutilizable
php
<?php
// src/Correo.php
declare(strict_types=1);
namespace App;
use PHPMailer\PHPMailer\PHPMailer;
final class Correo
{
/**
* Crea un PHPMailer ya configurado con los datos del .env
*/
public static function nuevo(): PHPMailer
{
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $_ENV['MAIL_HOST'];
$mail->Port = (int) $_ENV['MAIL_PORT'];
$mail->SMTPAuth = ($_ENV['MAIL_USER'] ?? '') !== '';
$mail->Username = $_ENV['MAIL_USER'] ?? '';
$mail->Password = $_ENV['MAIL_PASS'] ?? '';
$mail->SMTPSecure = match ((int) $_ENV['MAIL_PORT']) {
465 => PHPMailer::ENCRYPTION_SMTPS,
587 => PHPMailer::ENCRYPTION_STARTTLS,
default => '', // p. ej. Mailpit en desarrollo
};
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME'] ?? '');
$mail->isHTML(true);
return $mail;
}
/**
* Renderiza una plantilla PHP con variables y devuelve el HTML
*/
public static function plantilla(string $nombre, array $datos = []): string
{
extract($datos, EXTR_SKIP); // convierte ['nombre' => 'Ana'] en $nombre
ob_start(); // captura la salida en lugar de imprimirla
require __DIR__ . "/../plantillas/correos/$nombre.php";
return ob_get_clean();
}
}Plantilla de correo (los clientes de correo sólo entienden HTML sencillo con estilos en línea):
php
<?php // plantillas/correos/pedido.php ?>
<div style="font-family:Arial,sans-serif;max-width:600px;margin:auto;color:#333">
<h2 style="color:#2563eb">¡Gracias por tu compra, <?= htmlspecialchars($nombre) ?>!</h2>
<p>Tu pedido <strong>#<?= (int) $pedidoId ?></strong> fue recibido.</p>
<table style="width:100%;border-collapse:collapse">
<?php foreach ($productos as $p): ?>
<tr>
<td style="padding:8px;border-bottom:1px solid #eee"><?= htmlspecialchars($p['nombre']) ?> × <?= (int) $p['cantidad'] ?></td>
<td style="padding:8px;border-bottom:1px solid #eee;text-align:right">$<?= number_format($p['precio'] * $p['cantidad'], 2) ?></td>
</tr>
<?php endforeach; ?>
</table>
<p style="font-size:18px;text-align:right"><strong>Total: $<?= number_format($total, 2) ?></strong></p>
<p style="font-size:12px;color:#888">Si tienes dudas, responde a este correo.</p>
</div>Uso:
php
<?php
use App\Correo;
$mail = Correo::nuevo();
$mail->addAddress('ana@correo.com', 'Ana');
$mail->Subject = 'Confirmación de pedido #1001';
$mail->Body = Correo::plantilla('pedido', [
'nombre' => 'Ana',
'pedidoId' => 1001,
'productos' => [['nombre' => 'Café', 'cantidad' => 2, 'precio' => 180]],
'total' => 360,
]);
$mail->AltBody = 'Tu pedido #1001 fue recibido. Total: $360.00';
$mail->send();Caso real: formulario de contacto
php
<?php
// contacto.php
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(__DIR__)->load();
use App\Correo;
use PHPMailer\PHPMailer\Exception;
$errores = [];
$enviado = false;
$datos = ['nombre' => '', 'email' => '', 'mensaje' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Honeypot: campo oculto que los humanos no ven y los bots sí llenan
if (!empty($_POST['sitio_web'])) {
exit; // es un bot: no hacemos nada
}
// Límite simple: un envío cada 60 segundos por sesión
if (time() - ($_SESSION['ultimo_contacto'] ?? 0) < 60) {
$errores['general'] = 'Espera un minuto antes de enviar otro mensaje.';
}
$datos = [
'nombre' => trim($_POST['nombre'] ?? ''),
'email' => trim($_POST['email'] ?? ''),
'mensaje' => trim($_POST['mensaje'] ?? ''),
];
if (mb_strlen($datos['nombre']) < 2) $errores['nombre'] = 'Escribe tu nombre.';
if (!filter_var($datos['email'], FILTER_VALIDATE_EMAIL)) $errores['email'] = 'Email inválido.';
if (mb_strlen($datos['mensaje']) < 10) $errores['mensaje'] = 'El mensaje es muy corto.';
if (!$errores) {
try {
$mail = Correo::nuevo();
$mail->addAddress($_ENV['MAIL_ADMIN']);
// Reply-To al visitante: al pulsar "Responder", le escribes a él.
// El From sigue siendo TU dominio (si pones el email del visitante, irá a spam).
$mail->addReplyTo($datos['email'], $datos['nombre']);
$mail->Subject = 'Nuevo mensaje de contacto de ' . $datos['nombre'];
$mail->Body = '<p><strong>Nombre:</strong> ' . htmlspecialchars($datos['nombre']) . '</p>'
. '<p><strong>Email:</strong> ' . htmlspecialchars($datos['email']) . '</p>'
. '<p>' . nl2br(htmlspecialchars($datos['mensaje'])) . '</p>';
$mail->AltBody = "Nombre: {$datos['nombre']}\nEmail: {$datos['email']}\n\n{$datos['mensaje']}";
$mail->send();
$_SESSION['ultimo_contacto'] = time();
$enviado = true;
} catch (Exception $e) {
error_log('Contacto: ' . $e->getMessage());
$errores['general'] = 'No pudimos enviar tu mensaje. Escríbenos por WhatsApp.';
}
}
}
?>
<?php if ($enviado): ?>
<p>¡Gracias! Te responderemos pronto.</p>
<?php else: ?>
<?php if (isset($errores['general'])): ?><p style="color:red"><?= htmlspecialchars($errores['general']) ?></p><?php endif; ?>
<form method="post">
<!-- Honeypot oculto con CSS -->
<input type="text" name="sitio_web" style="display:none" tabindex="-1" autocomplete="off">
<input name="nombre" placeholder="Nombre" value="<?= htmlspecialchars($datos['nombre']) ?>">
<small><?= $errores['nombre'] ?? '' ?></small>
<input name="email" type="email" placeholder="Email" value="<?= htmlspecialchars($datos['email']) ?>">
<small><?= $errores['email'] ?? '' ?></small>
<textarea name="mensaje" placeholder="¿En qué te ayudamos?"><?= htmlspecialchars($datos['mensaje']) ?></textarea>
<small><?= $errores['mensaje'] ?? '' ?></small>
<button>Enviar</button>
</form>
<?php endif; ?>Adjuntos e imágenes incrustadas
php
<?php
// Adjuntar un archivo del servidor
$mail->addAttachment(__DIR__ . '/facturas/F-1001.pdf', 'Factura-1001.pdf');
// Adjuntar contenido generado en memoria (por ejemplo, un PDF creado con Dompdf)
$mail->addStringAttachment($contenidoPdf, 'cotizacion.pdf', PHPMailer::ENCODING_BASE64, 'application/pdf');
// Incrustar una imagen y referenciarla en el HTML con cid:
$mail->addEmbeddedImage(__DIR__ . '/img/logo.png', 'logo');
$mail->Body = '<img src="cid:logo" alt="Logo" width="120"><p>...</p>';Probar en desarrollo con Mailpit
Mailpit es un servidor SMTP falso con una bandeja web. Descarga el ejecutable desde su repositorio de GitHub (axllent/mailpit), ejecútalo y configura:
env
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USER=
MAIL_PASS=Abre http://localhost:8025 y verás todos los correos que envía tu aplicación, sin riesgo de escribirle a clientes reales.
Que no lleguen a spam
- Envía siempre desde el dominio del sitio (
From: contacto@dominio.com), autenticado por SMTP. - Configura en el DNS del dominio los registros SPF, DKIM y DMARC. En cPanel: "Email Deliverability" los configura casi automáticamente.
- Incluye siempre
AltBody(versión texto). - Evita asuntos en mayúsculas o llenos de signos ("¡¡¡OFERTA GRATIS!!!").
- Para envíos masivos (newsletters) usa un servicio especializado, no el SMTP del hosting.
Error común
Poner el email del visitante como remitente (setFrom($_POST['email'])). Tu servidor no está autorizado a enviar en nombre de gmail.com, así que el correo será rechazado o irá a spam. Usa tu dominio en setFrom y el del visitante en addReplyTo.
Error común
Insertar datos del usuario en el HTML del correo sin escapar. Un mensaje con código HTML malicioso se renderizará en el cliente de correo del administrador. Usa htmlspecialchars también en los correos.
Ejercicios
- Crea un script que envíe a un correo de prueba un mensaje con un adjunto (cualquier PDF) y una imagen incrustada.
- Crea la plantilla
plantillas/correos/bienvenida.phpy una funciónenviarBienvenida(string $email, string $nombre): boolque devuelvatrueofalsesin lanzar excepciones. - Modifica el formulario de contacto para que, además de avisar al administrador, envíe un correo de confirmación automática al visitante.
Ver solución
php
<?php
// 1
require __DIR__ . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(__DIR__)->load();
use App\Correo;
$mail = Correo::nuevo();
$mail->addAddress('pruebas@midominio.com');
$mail->Subject = 'Prueba con adjunto';
$mail->addAttachment(__DIR__ . '/docs/manual.pdf');
$mail->addEmbeddedImage(__DIR__ . '/img/logo.png', 'logo');
$mail->Body = '<img src="cid:logo" width="100" alt="Logo"><p>Te adjunto el manual.</p>';
$mail->AltBody = 'Te adjunto el manual.';
$mail->send();
echo 'Enviado';php
<?php // plantillas/correos/bienvenida.php ?>
<div style="font-family:Arial,sans-serif">
<h2>¡Hola, <?= htmlspecialchars($nombre) ?>!</h2>
<p>Tu cuenta ya está activa. Puedes iniciar sesión cuando quieras.</p>
</div>php
<?php
// 2
use App\Correo;
use PHPMailer\PHPMailer\Exception;
function enviarBienvenida(string $email, string $nombre): bool
{
try {
$mail = Correo::nuevo();
$mail->addAddress($email, $nombre);
$mail->Subject = "Bienvenido, $nombre";
$mail->Body = Correo::plantilla('bienvenida', ['nombre' => $nombre]);
$mail->AltBody = "Hola, $nombre. Tu cuenta ya está activa.";
return $mail->send();
} catch (Exception $e) {
error_log("Bienvenida a $email falló: " . $e->getMessage());
return false;
}
}php
<?php
// 3: dentro del try del formulario, tras enviar al admin
$confirmacion = Correo::nuevo();
$confirmacion->addAddress($datos['email'], $datos['nombre']);
$confirmacion->Subject = 'Recibimos tu mensaje';
$confirmacion->Body = '<p>Hola, ' . htmlspecialchars($datos['nombre'])
. '. Recibimos tu mensaje y te responderemos en menos de 24 horas.</p>';
$confirmacion->AltBody = "Hola, {$datos['nombre']}. Recibimos tu mensaje.";
$confirmacion->send();Resumen
- Evita
mail(): usa PHPMailer con SMTP autenticado. - Guarda credenciales en
.env, nunca en el código. - Puerto 465 con
ENCRYPTION_SMTPS, 587 conENCRYPTION_STARTTLS;CharSeten UTF-8. - Remitente siempre de tu dominio; el visitante va en
addReplyTo. - Incluye
AltBody, escapa los datos del usuario y configura SPF, DKIM y DMARC. - En desarrollo usa Mailpit para no enviar correos reales.