CreaRack-SL

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_BACKEND
  • allauth.account.forms.EmailAwarePasswordResetTokenGenerator → generación del token del enlace
  • django.utils.http.int_to_base36 → codificación del UID
  • django.template.loader.render_to_string → template welcome.html
  • anymail.message.AnymailMessage (si RESEND_API_KEY está configurada)

Responsabilidades

  1. 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]]
  2. Construir el contexto de template:

    • user_name, username, organization_name, role_display
    • login_url: base pública del SaaS desde settings.SITE_URL
    • set_password_url: el enlace con token
    • expires_days: caducidad del enlace en días (derivado de PASSWORD_RESET_TIMEOUT, default 3)
    • year: para el pie de página
  3. Renderizar y enviar:

    • Template: templates/emails/welcome.html (MJML-based, responsive)
    • Backend de email: usa ANYMAIL + RESEND_API_KEY en producción; en desarrollo/tests usa locmem
    • Corta en silencio sin RESEND_API_KEY (no fallar en dev)

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:

  1. Verifica que el usuario tenga email no vacío
  2. Calcula expires_days a partir de PASSWORD_RESET_TIMEOUT
  3. Renderiza el template con contexto (incluye set_password_url y expires_days)
  4. Envía vía anymail.send()
  5. Retorna True si OK, False si no hay RESEND_API_KEY o 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 el user.pk
  • Si cualquier cambio al usuario ocurre dentro de la misma transacción DESPUÉS del save (ej: user.last_login en tests, flujo admin con password inmediato), el token ya guardado en el email se invalida
  • Usar on_commit garantiza 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

CampoTipoOrigenNota
user.emailstrModel UserObligatorio para enviar
user.first_namestrModel UserMostrado como “Hola {first_name}” en el email; fallback a username
user.organization.namestrFK a OrganizationContexto del email
user.rolestrModel UserEnum (admin/operator/viewer/member); mostrado via ROLE_LABELS
user.password (hash)strModel UserInput para el token; debe estar final
settings.SITE_URLstrConfigDefault https://crearack.com
settings.PASSWORD_RESET_TIMEOUTintConfigDefault 259200 (3 días), en segundos

Salida

ArtefactoMedioNota
Email HTML/textRESEND (prod) o locmem (tests)Template responsive MJML, i18n ES/EN
Return valueboolTrue 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 (ej https://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:

  1. test_activation_link_token_is_valid: flujo completo (signup → email real → token valida)
  2. test_email_links_respect_site_url: con SITE_URL fijada, los enlaces apuntan a ese host
  3. test_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]]