Servicio send_welcome_email — email de bienvenida con enlace de set password
Ubicación y dependencias
Archivo: core/email_service.py
Función pública: send_welcome_email(user: User) → bool
Dependencias:
django.conf.settings→SITE_URL,PASSWORD_RESET_TIMEOUT,EMAIL_BACKENDallauth.account.forms.EmailAwarePasswordResetTokenGenerator→ generación del token del enlacedjango.utils.http.int_to_base36→ codificación del UIDdjango.template.loader.render_to_string→ templatewelcome.htmlanymail.message.AnymailMessage(siRESEND_API_KEYestá configurada)
Responsabilidades
-
Generar el token del enlace “Set password”:
_generate_set_password_url(user)crea un URL tipo{SITE_URL}/accounts/password/reset/key/{uidb36}-{token}/- El token se calcula sobre el hash del password del usuario y su PK → si el password cambia después, el token se invalida
- Nota arquitectónica (v1.65.3): el email debe enviarse DESPUÉS de que el usuario esté persistido en BD con su password final. Ver [[entity—core—service—send-welcome-emailmecanica-de-llamada-post-save-con-transaction-on-commit]]
-
Construir el contexto de template:
user_name,username,organization_name,role_displaylogin_url: base pública del SaaS desdesettings.SITE_URLset_password_url: el enlace con tokenexpires_days: caducidad del enlace en días (derivado dePASSWORD_RESET_TIMEOUT, default 3)year: para el pie de página
-
Renderizar y enviar:
- Template:
templates/emails/welcome.html(MJML-based, responsive) - Backend de email: usa
ANYMAIL+RESEND_API_KEYen producción; en desarrollo/tests usalocmem - Corta en silencio sin
RESEND_API_KEY(no fallar en dev)
- Template:
Sub-componentes
_generate_set_password_url(user: User) → str
Genera la URL del enlace de set password.
Lógica:
token_generator = EmailAwarePasswordResetTokenGenerator()
token = token_generator.make_token(user) # hash(user.password + user.pk + ...)
uidb36 = int_to_base36(user.pk) # PK codificado en base 36
return f"{settings.SITE_URL}/accounts/password/reset/key/{uidb36}-{token}/"
Dependencia de SITE_URL (v1.65.3): El hardcode anterior https://crearack.com se reemplazó para permitir que STAGE/dev generen URLs hacia su propio host.
send_welcome_email(user: User) → bool
Función pública que renderiza y envía el email.
Flujo:
- Verifica que el usuario tenga
emailno vacío - Calcula
expires_daysa partir dePASSWORD_RESET_TIMEOUT - Renderiza el template con contexto (incluye
set_password_urlyexpires_days) - Envía vía
anymail.send() - Retorna
Truesi OK,Falsesi no hayRESEND_API_KEYo error
Errores silenciosos: Si RESEND_API_KEY no está configurada (dev/tests sin override), retorna False sin lanzar excepción. Los tests que necesitan observar el outbox usan override_settings(ANYMAIL={"RESEND_API_KEY": "test-key"}, EMAIL_BACKEND="locmem").
Mecánica de llamada: post_save con transaction.on_commit
Invoker: Señal send_welcome_on_create en core/signals.py.
@receiver(post_save, sender="core.User")
def send_welcome_on_create(sender, instance, created, **kwargs):
if created and instance.email:
from .email_service import send_welcome_email
transaction.on_commit(lambda: send_welcome_email(instance))
Por qué transaction.on_commit (v1.65.3, fix grave):
- El token del enlace se calcula sobre
user.password(su hash) y eluser.pk - Si cualquier cambio al usuario ocurre dentro de la misma transacción DESPUÉS del save (ej:
user.last_loginen tests, flujo admin con password inmediato), el token ya guardado en el email se invalida - Usar
on_commitgarantiza que el email sale DESPUÉS del commit → la instancia tiene su estado final
Contexto: Bug cazado en plan-review de F1 (task #137). El endpoint de signup hacía set_unusable_password() después del create_user, invalidando el token. Fijo en v1.65.3.
Datos de entrada y salida
Entrada
| Campo | Tipo | Origen | Nota |
|---|---|---|---|
user.email | str | Model User | Obligatorio para enviar |
user.first_name | str | Model User | Mostrado como “Hola {first_name}” en el email; fallback a username |
user.organization.name | str | FK a Organization | Contexto del email |
user.role | str | Model User | Enum (admin/operator/viewer/member); mostrado via ROLE_LABELS |
user.password (hash) | str | Model User | Input para el token; debe estar final |
settings.SITE_URL | str | Config | Default https://crearack.com |
settings.PASSWORD_RESET_TIMEOUT | int | Config | Default 259200 (3 días), en segundos |
Salida
| Artefacto | Medio | Nota |
|---|---|---|
| Email HTML/text | RESEND (prod) o locmem (tests) | Template responsive MJML, i18n ES/EN |
| Return value | bool | True si enviado, False si no hay API key |
Template
Archivo: templates/emails/welcome.html
Variables esperadas:
{{ user_name }}— nombre o username{{ username }}— username (login){{ organization_name }}— contexto de la org{{ role_display }}— nombre del rol (traducido){{ login_url }}— base pública (ejhttps://crearack.com){{ set_password_url }}— enlace completo con token{{ expires_days }}— caducidad del enlace en días{{ year }}— año del pie
Traducciones: El email tiene bloques {% blocktrans %} y {% trans %} para ES/EN. La caducidad está i18n desde v1.65.3.
Testing
Test file: tests/api/test_signup_activation.py
Test cases:
test_activation_link_token_is_valid: flujo completo (signup → email real → token valida)test_email_links_respect_site_url: conSITE_URLfijada, los enlaces apuntan a ese hosttest_admin_created_user_token_also_valid: creación de usuario por admin también genera tokens válidos
Setup necesario: django_capture_on_commit_callbacks(execute=True) en cada test, porque transaction.on_commit no dispara automáticamente bajo pytest.
Véase también
- [[feature—auth—email-welcome-activation-link]]
- [[entity—core—service—signup-activation]]
- [[entity—core—model—user]]
- [[entity—core—endpoint—signup]]
- [[entity—config—setting—site-url]]