Saltar al contenido
🐍 Curso de Python
/

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.

⏱ 16 min de lectura

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#

agenda/settings.py
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:

Salida en pantalla
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_TIMEOUT
Salida en pantalla
The {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:

python
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:

Salida en pantalla
host, port, username, password, use_tls, use_ssl, timeout,
ssl_keyfile, ssl_certfile, fail_silently

Los cinco backends#

BackendPara qué
consoleDesarrollo: lo imprime en la consola del servidor
locmemPruebas: lo guarda en una lista en memoria
filebasedDesarrollo: un fichero por mensaje
smtpProducción
dummyLo tira; sirve para medir sin enviar

El de consola, que es el que viene puesto, imprime esto:

Salida en pantalla
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:

python
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:

python
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()
Salida en pantalla
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:

python
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:

python
MAILERS = {
    'default': {'BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 'OPTIONS': {...}},
    'avisos':  {'BACKEND': 'django.core.mail.backends.smtp.EmailBackend', 'OPTIONS': {...}},
}
python
send_mail('Al de por defecto', 'x', None, ['c@d.es'])
send_mail('Al de avisos', 'x', None, ['c@d.es'], using='avisos')
Salida en pantalla
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:

Salida en pantalla
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:

Salida en pantalla
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 testserver

El 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:

Salida en pantalla
correo inexistente -> estado: 302 | correos enviados: 0

Misma 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.