Tema
Correos con nodemailer
nodemailer es la librería estándar para enviar correos desde Node.js por SMTP. Este snippet crea un servicio reutilizable con plantillas HTML, versión en texto plano, adjuntos y un modo de pruebas que no envía correos reales.
Instalación
bash
npm install nodemailerenv
SMTP_HOST=smtp.tudominio.com
SMTP_PORT=587
SMTP_SECURE=false # true para el puerto 465
SMTP_USER=no-responder@tudominio.com
SMTP_PASS=contraseña
MAIL_FROM="Mi Negocio <no-responder@tudominio.com>"Servicio de correo
js
import nodemailer from 'nodemailer';
import 'dotenv/config';
let transporter;
/** Crea el transporte una sola vez (y una cuenta de prueba si no hay SMTP configurado) */
async function obtenerTransporte() {
if (transporter) return transporter;
if (!process.env.SMTP_HOST) {
// Modo desarrollo: cuenta falsa de Ethereal (los correos se ven en una URL)
const prueba = await nodemailer.createTestAccount();
transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email', port: 587,
auth: { user: prueba.user, pass: prueba.pass },
});
console.log('📧 Usando Ethereal (correos de prueba)');
} else {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true, // reutiliza conexiones si envías muchos
maxConnections: 3,
});
await transporter.verify(); // falla rápido si las credenciales están mal
}
return transporter;
}
const escapar = (t = '') => String(t).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
/** Diseño base compatible con clientes de correo (tablas + estilos en línea) */
function plantilla({ titulo, contenido, boton }) {
const btn = boton
? `<p style="margin:28px 0"><a href="${boton.url}" style="background:#4f46e5;color:#fff;padding:12px 22px;border-radius:8px;text-decoration:none;font-weight:bold">${escapar(boton.texto)}</a></p>`
: '';
return `<!DOCTYPE html><html lang="es"><body style="margin:0;background:#f1f5f9;font-family:Arial,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:24px 0"><tr><td align="center">
<table width="600" style="max-width:600px;background:#fff;border-radius:12px;overflow:hidden">
<tr><td style="background:#4f46e5;color:#fff;padding:20px 28px;font-size:20px;font-weight:bold">Mi Negocio</td></tr>
<tr><td style="padding:28px;color:#1e293b;font-size:15px;line-height:1.6">
<h2 style="margin-top:0">${escapar(titulo)}</h2>${contenido}${btn}
</td></tr>
<tr><td style="padding:16px 28px;background:#f8fafc;color:#64748b;font-size:12px">© ${new Date().getFullYear()} Mi Negocio · Ciudad de México</td></tr>
</table>
</td></tr></table></body></html>`;
}
/**
* Envía un correo.
* @param {{ para: string|string[], asunto: string, titulo?: string, html: string,
* boton?: {texto: string, url: string}, adjuntos?: object[], responderA?: string }} opciones
*/
export async function enviarCorreo({ para, asunto, titulo, html, boton, adjuntos = [], responderA }) {
const t = await obtenerTransporte();
const info = await t.sendMail({
from: process.env.MAIL_FROM ?? 'Mi Negocio <no-responder@ejemplo.com>',
to: para,
replyTo: responderA,
subject: asunto,
html: plantilla({ titulo: titulo ?? asunto, contenido: html, boton }),
text: html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), // versión texto
attachments: adjuntos, // [{ filename: 'factura.pdf', path: './facturas/1.pdf' }]
});
const vistaPrevia = nodemailer.getTestMessageUrl(info);
if (vistaPrevia) console.log('Vista previa del correo:', vistaPrevia);
return info;
}
// Correos frecuentes listos para usar
export const correos = {
bienvenida: (usuario) => enviarCorreo({
para: usuario.email,
asunto: '¡Bienvenido a Mi Negocio!',
html: `<p>Hola ${escapar(usuario.nombre)}, gracias por registrarte.</p>`,
boton: { texto: 'Ir a mi cuenta', url: `${process.env.APP_URL}/cuenta` },
}),
restablecer: (usuario, token) => enviarCorreo({
para: usuario.email,
asunto: 'Restablece tu contraseña',
html: '<p>Recibimos una solicitud para cambiar tu contraseña. El enlace caduca en 1 hora.</p><p>Si no fuiste tú, ignora este mensaje.</p>',
boton: { texto: 'Crear nueva contraseña', url: `${process.env.APP_URL}/restablecer?token=${token}` },
}),
confirmacionPedido: (pedido) => enviarCorreo({
para: pedido.email,
asunto: `Pedido #${pedido.id} confirmado`,
html: `<p>Recibimos tu pedido por <b>${new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(pedido.total)}</b>.</p>`,
}),
};Uso en una ruta
js
import { correos, enviarCorreo } from '../utils/correo.js';
// Tras registrar: no hagas esperar al usuario por el correo
correos.bienvenida(usuario).catch((err) => console.error('No se envió la bienvenida', err));
// Formulario de contacto
router.post('/contacto', asyncHandler(async (req, res) => {
const { nombre, email, mensaje } = req.body; // valídalo con zod antes
await enviarCorreo({
para: process.env.CONTACTO_DESTINO,
asunto: `Nuevo mensaje de ${nombre}`,
html: `<p><b>De:</b> ${nombre} (${email})</p><p>${mensaje}</p>`,
responderA: email,
});
res.json({ ok: true, mensaje: 'Gracias, te responderemos pronto' });
}));Escapa siempre
En el ejemplo de contacto, pasa nombre y mensaje por la función escapar() (expórtala) antes de meterlos en el HTML; si no, alguien puede inyectar enlaces o HTML en los correos que recibe tu cliente.
Envíos masivos o críticos
Para cientos de correos (boletines, notificaciones) usa una cola (BullMQ + Redis) o un servicio transaccional con API (Resend, Brevo, Amazon SES, Postmark). Mejoran la entregabilidad y te dan estadísticas de apertura y rebotes.
Error común
Con Gmail, usar tu contraseña normal. Google la rechaza: necesitas activar la verificación en dos pasos y crear una contraseña de aplicación. Y Gmail limita a unos 500 correos diarios.