Volver a la wiki

El email de bienvenida ya trae un enlace que funciona

Problema

Al registrar una nueva organización vía /api/signup/, el endpoint enviaba un email de bienvenida con un enlace “Crea tu contraseña” que nacía siempre inválido o expirado. El destinatario veía invalid or expired link y no podía completar el alta.

Causa raíz: El endpoint regeneraba el password DESPUÉS de que la señal post_save calculara el token del link. Como el token hashea el password, el recalculado lo invalidaba inmediatamente. Nunca fue un problema en vivo porque el endpoint no tenía UI pública, pero se habría manifestado en el primer cliente fundador.

Cazado por: Los revisores fríos del plan de F1 en sesión de plan-review (2026-08-04).

Solución (v1.65.3)

Backend

  1. Password fijado una sola vez: el método create_user ya deja el password inusable; no se toca después. Eliminada la llamada redundante a set_unusable_password() y user.save(update_fields=["password"]).
  2. Email enviado DESPUÉS del commit: la señal ahora usa transaction.on_commit(lambda: send_welcome_email(instance)). Así el token se calcula sobre el estado final de la BD, no el intermedio del save.
  3. URLs dinámicas: los links de email ahora salen de la setting SITE_URL en lugar del hardcode https://crearack.com. Permite que STAGE genere enlaces hacia su propio host.
  4. Caducidad del enlace visible: el email declara que el enlace expira en 3 días (valor de PASSWORD_RESET_TIMEOUT), con traducción ES.

Testing (nuevo)

Detalles técnicos

Cambios en core/signals.py

@receiver(post_save, sender="core.User")
def send_welcome_on_create(sender, instance, created, **kwargs):
    """Send welcome email AFTER the creating transaction commits."""
    if created and instance.email:
        from .email_service import send_welcome_email
        transaction.on_commit(lambda: send_welcome_email(instance))

El on_commit es imprescindible, no cosmético. Sin él, cualquier cambio al usuario dentro de la misma transacción (como el que hacía last_login en los tests) invalidaba el token del enlace.

Cambios en core/email_service.py

def _generate_set_password_url(user):
    token_generator = EmailAwarePasswordResetTokenGenerator()
    token = token_generator.make_token(user)
    uidb36 = int_to_base36(user.pk)
    return f"{settings.SITE_URL}/accounts/password/reset/key/{uidb36}-{token}/"

def send_welcome_email(user):
    # ...
    expires_days = int(getattr(settings, "PASSWORD_RESET_TIMEOUT", 259200)) // 86400
    context = {
        # ...
        "login_url": settings.SITE_URL,
        "set_password_url": _generate_set_password_url(user),
        "expires_days": expires_days,
    }

Nueva setting en config/settings/base.py

SITE_URL = os.getenv("SITE_URL") or "https://crearack.com"

Template actualizado

El email de bienvenida ahora muestra:

Este enlace caduca en {{ expires_days }} días. Si ha caducado, usa "¿Has olvidado tu contraseña?" en la página de acceso para solicitar uno nuevo.

Incluye traducción ES en locale/es/LC_MESSAGES/django.po.

Contexto (F1 PR-0)

El mismo síntoma volvió meses después, con una causa raíz distinta: el enlace “crea tu contraseña” del email de bienvenida (y el de restablecer contraseña) mostraba “expirado” al pulsarlo desde el correo, pero funcionaba si se pegaba la URL a mano en el navegador — un falso “discriminante de incógnito” que llevaba tiempo confundiendo al equipo.

Causa raíz: SESSION_COOKIE_SAMESITE = "Strict". Un click desde un email llega como navegación cross-site; con Strict el navegador retiene la cookie de sesión en el salto de la redirección del propio dominio, así que el paso set-password de allauth (que relee el token estashado EN LA SESIÓN) llegaba sin sesión y caía a la rama token_fail. Pegar la URL a mano es una navegación “typed”, y ahí la cookie Strict sí viaja — de ahí el comportamiento fantasma.

Diagnóstico: evidencia de PROD — el primer GET del enlace siempre devolvía 302 (el token era válido), un curl con cookie jar veía el formulario de contraseña, pero el navegador real no. Cazado en vivo durante el ensayo de la organización de prueba de la tarea #256.

Fix: SESSION_COOKIE_SAMESITE = "Lax" (el default de Django). El CSRF real lo cubren el middleware + token de Django, y Lax sigue sin enviar cookies en peticiones POST cross-site, que es donde vive el riesgo real.

Tests (tests/api/test_signup_activation.py):

Esto no invalida la solución de v1.65.3 (el bug del token de password): son dos causas raíz distintas del mismo síntoma visible, sobre el mismo flujo de activación, cazadas en momentos distintos.

Véase también

Subir