Módulo 4 · Lección 38 de 45
Envío de correo
El ajuste que Django 6 acaba de cambiar entero, el correo que se pierde sin decir nada, y la recuperación de contraseña que quedó a medias en la lección 32.
La lección 32 dejó montada la recuperación de contraseña y con una pieza sin poner: el correo que lleva el enlace. Esta lección la pone, y de paso cubre el envío de correo en general, que hace falta para los avisos, las confirmaciones y los informes de error.
Y hay un motivo extra para mirarlo con calma: Django 6 ha cambiado la configuración de correo entera. Lo que enseña prácticamente todo el material que hay por ahí está marcado para desaparecer.
MAILERS#
MAILERS = {
'default': {
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
},
}Un diccionario de alias, exactamente igual que DATABASES, CACHES y el STORAGES de la lección 29. Django avanza hacia que toda la configuración tenga esta forma, y el correo es lo último que faltaba.
Eso no hay ni que escribirlo: startproject ya lo deja puesto, con el backend de consola, que es justo lo que quieres en desarrollo. Es nuevo de la 6.1; antes no venía nada y el correo fallaba con un error de conexión hasta que lo configurabas.
Lo que había antes, y lo que enseñan todos los tutoriales, era una lista de once ajustes sueltos:
EMAIL_BACKEND EMAIL_HOST_PASSWORD EMAIL_USE_SSL
EMAIL_FILE_PATH EMAIL_PORT EMAIL_USE_TLS
EMAIL_HOST EMAIL_SSL_CERTFILE
EMAIL_HOST_USER EMAIL_SSL_KEYFILE EMAIL_TIMEOUTThe {name} setting is deprecated. Migrate to MAILERS before Django 7.0.Los once están obsoletos y desaparecen en Django 7.0. Sirven todavía, pero es código con fecha de caducidad.
Con MAILERS, esos ajustes van dentro, en OPTIONS:
MAILERS = {
'default': {
'BACKEND': 'django.core.mail.backends.smtp.EmailBackend',
'OPTIONS': {
'host': 'smtp.ejemplo.es',
'port': 587,
'username': 'avisos@ejemplo.es',
'password': os.environ['CLAVE_SMTP'],
'use_tls': True,
'timeout': 10,
},
},
}Las opciones que acepta el backend de SMTP son exactamente las de antes, en minúsculas:
host, port, username, password, use_tls, use_ssl, timeout,
ssl_keyfile, ssl_certfile, fail_silentlyLos cinco backends#
| Backend | Para qué |
|---|---|
console | Desarrollo: lo imprime en la consola del servidor |
locmem | Pruebas: lo guarda en una lista en memoria |
filebased | Desarrollo: un fichero por mensaje |
smtp | Producción |
dummy | Lo tira; sirve para medir sin enviar |
El de consola, que es el que viene puesto, imprime esto:
Subject: Recupera tu =?utf-8?q?contrase=C3=B1a?=
From: webmaster@localhost
To: ana@ejemplo.es
Date: Tue, 22 Sep 2026 17:54:40 +0000
Pulsa aquí: https://ejemplo.es/x/Se ve el mensaje entero sin montar nada, y el asunto aparece codificado porque lleva una eñe. Eso es normal y es cómo viajan los acentos en las cabeceras de correo; el cliente lo descodifica al enseñarlo.
Ese From: webmaster@localhost es el valor por defecto de DEFAULT_FROM_EMAIL, y es una de las cosas que hay que cambiar antes de que alguien lo vea.
Enviar#
Para lo sencillo, una función:
from django.core.mail import send_mail
send_mail('Asunto', 'Cuerpo en texto.', None, ['ana@ejemplo.es'])El None del remitente usa DEFAULT_FROM_EMAIL, que es lo que quieres casi siempre. Devuelve cuántos mensajes se enviaron, que es 1 o 0.
Para todo lo demás, la clase:
from django.core.mail import EmailMultiAlternatives
mensaje = EmailMultiAlternatives(
'Dos', 'versión en texto', None, ['c@d.es'],
cc=['cc@d.es'], bcc=['oculto@d.es'], reply_to=['responde@d.es'],
)
mensaje.attach_alternative('<p>versión en <b>HTML</b></p>', 'text/html')
mensaje.attach('nota.txt', b'contenido', 'text/plain')
mensaje.send()to: ['c@d.es'] | cc: ['cc@d.es'] | bcc: ['oculto@d.es'] | reply_to: ['responde@d.es']
alternativas: ['text/html']
adjuntos: [('nota.txt', 'text/plain')]
destinatarios reales: ['c@d.es', 'cc@d.es', 'oculto@d.es']Fíjate en la última línea: recipients() junta los tres, que es a quién se le manda de verdad. Y la copia oculta está oculta para el que lo recibe, no para el servidor.
Lo de attach_alternative es la forma correcta de mandar HTML: se manda el mismo mensaje dos veces, en texto y en HTML, y cada cliente enseña el que puede. Mandar solo HTML es lo que hace que el mensaje acabe en la carpeta de correo no deseado.
Y el cuerpo, lógicamente, sale de una plantilla:
from django.template.loader import render_to_string
mensaje.attach_alternative(
render_to_string('correo/aviso.html', {'contacto': contacto}), 'text/html'
)Varios destinos#
Un proyecto suele tener dos correos distintos: el de los usuarios, que va por un servicio de envío, y el de los avisos internos. Con MAILERS son dos alias:
MAILERS = {
'default': {'BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 'OPTIONS': {...}},
'avisos': {'BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 'OPTIONS': {...}},
}send_mail('Al de por defecto', 'x', None, ['c@d.es'])
send_mail('Al de avisos', 'x', None, ['c@d.es'], using='avisos')alias: ['default', 'avisos']
MailerDoesNotExist: "The mailer 'inventado' is not configured."Ese using= es el cambio de forma que trae MAILERS, y sustituye a tres argumentos que también están obsoletos:
The 'connection' argument is deprecated. Switch to the 'using' argument with a MAILERS alias.
The 'auth_user' and 'auth_password' arguments are deprecated. Set 'username' and 'password' OPTIONS in MAILERS instead.
The 'fail_silently' argument is deprecated.La idea de fondo es buena: la configuración va en los ajustes y el código solo elige un alias. Antes se podían pasar credenciales en la llamada, que es justo donde no deben estar.
La recuperación de contraseña, por fin#
Con el backend de consola puesto, lo de la lección 32 ya funciona entero:
POST /cuentas/password_reset/ -> 302 /cuentas/password_reset/done/
correos: 1
asunto: 'Contraseña restablecida en testserver'
de: webmaster@localhost | para: ['recu@ejemplo.es']
Ha recibido este correo electrónico porque ha solicitado restablecer
la contraseña para su cuenta en testserver.
Por favor, vaya a la página siguiente y escoja una nueva contraseña.
http://testserver/cuentas/reset/OQ/dfbooc-d45cb34a78ec1f36dfa99ce6cd18d4ca/
Por si lo ha olvidado, usted es: recu
¡Gracias por usar nuestro sitio!
El equipo de testserverEl texto está en español porque el LANGUAGE_CODE de la lección 5 está puesto. Y hay tres cosas ahí que hay que arreglar antes de enseñárselo a nadie:
testserver sale cuatro veces. Es el nombre del servidor, sacado de la petición. En producción saldrá tu dominio, lo cual está bien, pero "El equipo de mi-tienda.es" no es lo que quieres firmar. Eso se arregla con django.contrib.sites o sobrescribiendo las plantillas registration/password_reset_email.html y registration/password_reset_subject.txt.
El enlace es http://, no https://. Django lo decide preguntando a la petición si es segura, y detrás de un proxy no lo sabe a menos que se lo digas. Es la lección 43, y es un fallo con consecuencias: un enlace de recuperación viajando sin cifrar.
El remitente sigue siendo webmaster@localhost.
Y lo que hace bien sin tocar nada:
correo inexistente -> estado: 302 | correos enviados: 0Misma respuesta, sin correo. Es la defensa contra el recuento de usuarios de la lección 32, y ahora se ve que llega hasta el final: no hay ninguna diferencia observable entre un correo registrado y uno que no.