Spaces:
Running
Running
| import logging | |
| from datetime import timedelta, datetime | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, status, Form, BackgroundTasks | |
| from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | |
| from sqlalchemy.orm import Session | |
| from app.database import get_db | |
| from app.models.user import User, Device, CreditTransaction | |
| from app.schemas.user import UserCreate, UserOut, Token, DeviceRegister, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, AvatarUpdate, UsernameUpdate | |
| from passlib.context import CryptContext | |
| from jose import JWTError, jwt | |
| import os | |
| from dotenv import load_dotenv | |
| from app.utils.notifications import create_admin_notification | |
| # ========================================== | |
| # CONFIGURACIÓN Y LOGGING | |
| # ========================================== | |
| load_dotenv() | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter(prefix="/auth", tags=["auth"]) | |
| # ========================================== | |
| # HELPER: Encolado proactivo de stems en la nube para nuevo dispositivo | |
| # ========================================== | |
| def _enqueue_cloud_stems_for_new_device(db: Session, user, new_device_id: str): | |
| """ | |
| No-op: La sincronización P2P por cola de dispositivos ha sido eliminada. | |
| """ | |
| pass | |
| # ========================================== | |
| # CONFIGURACIÓN DE SEGURIDAD | |
| # ========================================== | |
| SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey_change_me_in_production") | |
| ALGORITHM = os.getenv("ALGORITHM", "HS256") | |
| ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200")) | |
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | |
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") | |
| # ========================================== | |
| # UTILIDADES | |
| # ========================================== | |
| def verify_password(plain_password, hashed_password): | |
| """Verifica si una contraseña coincide con el hash""" | |
| try: | |
| return pwd_context.verify(plain_password, hashed_password) | |
| except Exception as e: | |
| logger.warning(f"Error verificando password: {e}") | |
| return False | |
| def get_password_hash(password): | |
| """Genera hash de una contraseña""" | |
| return pwd_context.hash(password) | |
| def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): | |
| """Crea un token JWT con los datos proporcionados""" | |
| to_encode = data.copy() | |
| if expires_delta: | |
| expire = datetime.utcnow() + expires_delta | |
| else: | |
| expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| to_encode.update({"exp": expire}) | |
| encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) | |
| return encoded_jwt | |
| SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", SECRET_KEY) | |
| async def get_current_user( | |
| token: str = Depends(oauth2_scheme), | |
| db: Session = Depends(get_db) | |
| ): | |
| """Obtiene el usuario actual validando el token JWT de Supabase o propio""" | |
| credentials_exception = HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Could not validate credentials", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| payload = None | |
| try: | |
| # Intentar decodificar con SUPABASE_JWT_SECRET primero (para Google/Supabase login) | |
| payload = jwt.decode( | |
| token, | |
| SUPABASE_JWT_SECRET, | |
| algorithms=[ALGORITHM], | |
| options={"verify_aud": False} | |
| ) | |
| except JWTError: | |
| # Si falla, intentar decodificar con SECRET_KEY (para login local email/password) | |
| try: | |
| payload = jwt.decode( | |
| token, | |
| SECRET_KEY, | |
| algorithms=[ALGORITHM], | |
| options={"verify_aud": False} | |
| ) | |
| except JWTError as e: | |
| logger.warning(f"Token inválido (ambas firmas fallaron): {e}") | |
| raise credentials_exception | |
| try: | |
| # En Supabase Auth, el correo del usuario viene en el campo 'email' | |
| email: str = payload.get("email") | |
| device_id: Optional[str] = payload.get("device_id") | |
| if email is None: | |
| # Fallback para tokens antiguos que usaban username en 'sub' | |
| username: str = payload.get("sub") | |
| if username: | |
| user = db.query(User).filter(User.username == username).first() | |
| if user: | |
| if device_id: | |
| device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first() | |
| if not device_exists: | |
| logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}") | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Session invalidated. Logged in from another device.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| return user | |
| logger.warning("Token inválido: no contiene email") | |
| raise credentials_exception | |
| except JWTError as e: | |
| logger.warning(f"Token inválido: {e}") | |
| raise credentials_exception | |
| # Buscar el usuario en nuestra tabla pública usando el email | |
| user = db.query(User).filter(User.email == email).first() | |
| if user is None: | |
| logger.warning(f"Usuario no encontrado: {email}") | |
| raise credentials_exception | |
| if getattr(user, "is_suspended", False): | |
| logger.warning(f"Intento de acceso de usuario suspendido: {user.username}") | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="user_suspended" | |
| ) | |
| # Validar el dispositivo si el token contiene device_id | |
| if device_id: | |
| device_exists = db.query(Device).filter(Device.user_id == user.id, Device.device_id == device_id).first() | |
| if not device_exists: | |
| logger.warning(f"Sesión invalidada: Dispositivo {device_id} ya no está registrado para el usuario {user.username}") | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Session invalidated. Logged in from another device.", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| return user | |
| # ========================================== | |
| # ENDPOINTS | |
| # ========================================== | |
| def register_user(user: UserCreate, db: Session = Depends(get_db)): | |
| """ | |
| Registra un nuevo usuario. | |
| - **username**: Nombre de usuario (único) | |
| - **email**: Correo electrónico (único) | |
| - **password**: Contraseña (se guarda encriptada) | |
| """ | |
| logger.info(f"Intento de registro para usuario: {user.username}") | |
| # Validar formato de username | |
| reg_username = user.username.strip() | |
| if not reg_username: | |
| raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío") | |
| if len(reg_username) < 3 or len(reg_username) > 20: | |
| raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres") | |
| import re | |
| if not re.match(r"^[a-zA-Z0-9.\-\s]+$", reg_username): | |
| raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones") | |
| # Verificar username | |
| db_user = db.query(User).filter(User.username == reg_username).first() | |
| if db_user: | |
| logger.warning(f"Registro fallido: username '{user.username}' ya existe") | |
| raise HTTPException(status_code=400, detail="Username already registered") | |
| # Verificar email | |
| db_email = db.query(User).filter(User.email == user.email).first() | |
| if db_email: | |
| logger.warning(f"Registro fallido: email '{user.email}' ya existe") | |
| raise HTTPException(status_code=400, detail="Email already registered") | |
| # Crear usuario | |
| hashed_password = get_password_hash(user.password) | |
| new_user = User( | |
| username=reg_username, | |
| email=user.email, | |
| hashed_password=hashed_password, | |
| credits=10 # Créditos iniciales de prueba | |
| ) | |
| db.add(new_user) | |
| db.commit() | |
| db.refresh(new_user) | |
| # Registrar transacción inicial de créditos en el historial | |
| initial_tx = CreditTransaction( | |
| user_id=new_user.id, | |
| amount=10, | |
| concept="inicial", | |
| reference_id=None, | |
| created_at=datetime.utcnow() | |
| ) | |
| db.add(initial_tx) | |
| db.commit() | |
| logger.info(f"Usuario registrado exitosamente: {user.username} (ID: {new_user.id})") | |
| try: | |
| create_admin_notification( | |
| db, | |
| "Nueva Cuenta Creada", | |
| f"El usuario '{new_user.username}' ({new_user.email}) se ha registrado." | |
| ) | |
| except Exception as e: | |
| logger.warning(f"Error al registrar notificación para el nuevo usuario: {e}") | |
| return new_user | |
| async def login_for_access_token( | |
| form_data: OAuth2PasswordRequestForm = Depends(), | |
| device_id: Optional[str] = Form(None), | |
| device_name: Optional[str] = Form(None), | |
| confirm_override: bool = Form(False), | |
| db: Session = Depends(get_db) | |
| ): | |
| """ | |
| Inicia sesión y obtiene un token de acceso. | |
| - **username**: Nombre de usuario | |
| - **password**: Contraseña | |
| - **device_id**: Identificador único del dispositivo (opcional) | |
| - **device_name**: Nombre amigable del dispositivo (opcional) | |
| """ | |
| logger.info(f"Intento de login para usuario: {form_data.username} desde dispositivo: {device_id}") | |
| # Verificar usuario usando el email (aunque el campo del formulario OAuth2 se llame 'username') | |
| user = db.query(User).filter(User.email == form_data.username).first() | |
| if not user: | |
| logger.warning(f"Login fallido: usuario con email '{form_data.username}' no existe") | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Incorrect email or password", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| if getattr(user, "is_suspended", False): | |
| logger.warning(f"Login fallido: usuario suspendido email '{form_data.username}'") | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Cuenta suspendida. Para más información contacta a soporte." | |
| ) | |
| if not verify_password(form_data.password, user.hashed_password): | |
| logger.warning(f"Login fallido: contraseña incorrecta para email '{form_data.username}'") | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Incorrect email or password", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| # ========================================== | |
| # GESTIÓN DE DISPOSITIVOS | |
| # ========================================== | |
| if device_id and device_id != "admin_dashboard": | |
| device = next((d for d in user.devices if d.device_id == device_id), None) | |
| if device: | |
| # Actualizar última actividad | |
| device.last_active = datetime.utcnow() | |
| logger.debug(f"Dispositivo conocido: {device.name}") | |
| db.commit() | |
| else: | |
| # Verificar límite de dispositivos | |
| max_devices = 3 if user.is_premium else 1 | |
| if len(user.devices) >= max_devices: | |
| if not confirm_override: | |
| logger.warning(f"Login fallido: límite de dispositivos alcanzado para {form_data.username}") | |
| raise HTTPException( | |
| status_code=403, | |
| detail="device_override_required" | |
| ) | |
| else: | |
| # El usuario confirma anulación: | |
| if user.is_premium: | |
| # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio | |
| oldest_device = db.query(Device).filter(Device.user_id == user.id).order_by(Device.last_active.asc()).first() | |
| if oldest_device: | |
| logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {user.username}") | |
| db.delete(oldest_device) | |
| else: | |
| # Para no premium, eliminamos todos | |
| db.query(Device).filter(Device.user_id == user.id).delete() | |
| db.commit() | |
| # Recargar relaciones | |
| db.refresh(user) | |
| logger.info(f"Anulando sesión anterior para {user.username} (confirm_override=True)") | |
| # Registrar nuevo dispositivo | |
| new_device = Device( | |
| user_id=user.id, | |
| device_id=device_id, | |
| name=device_name or "Desconocido" | |
| ) | |
| db.add(new_device) | |
| logger.info(f"Nuevo dispositivo registrado: {new_device.name} ({device_id})") | |
| db.commit() | |
| # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo | |
| _enqueue_cloud_stems_for_new_device(db, user, device_id) | |
| # ========================================== | |
| # GENERAR TOKEN | |
| # ========================================== | |
| access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) | |
| token_payload = {"sub": user.username, "email": user.email} | |
| if device_id and device_id != "admin_dashboard": | |
| token_payload["device_id"] = device_id | |
| access_token = create_access_token( | |
| data=token_payload, expires_delta=access_token_expires | |
| ) | |
| logger.info(f"Login exitoso: {user.email}") | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, | |
| "user": { | |
| "id": user.id, | |
| "username": user.username, | |
| "credits": user.credits, | |
| "is_premium": user.is_premium, | |
| "avatar_url": user.avatar_url, | |
| "username_changes_this_month": user.username_changes_this_month, | |
| "last_username_change_at": user.last_username_change_at, | |
| "is_forum_banned": user.is_forum_banned | |
| } | |
| } | |
| async def read_users_me( | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Obtiene la información del usuario autenticado""" | |
| logger.debug(f"Usuario {current_user.username} consultando perfil") | |
| now = datetime.utcnow() | |
| if current_user.last_username_change_at: | |
| last_change = current_user.last_username_change_at | |
| if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month): | |
| current_user.username_changes_this_month = 0 | |
| db.commit() | |
| return current_user | |
| async def logout( | |
| device_id: str = Form(...), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Cierra sesión eliminando el dispositivo registrado. | |
| Útil cuando el usuario quiere revocar acceso a un dispositivo. | |
| """ | |
| logger.info(f"Logout solicitado por {current_user.username} para dispositivo: {device_id}") | |
| device = next((d for d in current_user.devices if d.device_id == device_id), None) | |
| if device: | |
| db.delete(device) | |
| db.commit() | |
| logger.info(f"Dispositivo {device_id} eliminado para usuario {current_user.username}") | |
| return {"message": "Logout exitoso. Dispositivo eliminado."} | |
| else: | |
| logger.warning(f"Dispositivo {device_id} no encontrado para {current_user.username}") | |
| raise HTTPException(status_code=404, detail="Device not found") | |
| async def list_devices( | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """Lista todos los dispositivos registrados del usuario""" | |
| logger.info(f"Usuario {current_user.username} consultando dispositivos") | |
| devices = [ | |
| { | |
| "id": d.id, | |
| "device_id": d.device_id, | |
| "name": d.name, | |
| "registered_at": d.registered_at.isoformat() if d.registered_at else None, | |
| "last_active": d.last_active.isoformat() if d.last_active else None | |
| } | |
| for d in current_user.devices | |
| ] | |
| return { | |
| "total": len(devices), | |
| "devices": devices | |
| } | |
| async def register_device( | |
| device_data: DeviceRegister, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Registra un dispositivo en segundo plano para un usuario ya autenticado (por ejemplo, con Google). | |
| """ | |
| logger.info(f"Registro de dispositivo solicitado por {current_user.username} (ID: {current_user.id})") | |
| device = next((d for d in current_user.devices if d.device_id == device_data.device_id), None) | |
| if device: | |
| device.last_active = datetime.utcnow() | |
| logger.debug(f"Dispositivo conocido: {device.name}") | |
| db.commit() | |
| else: | |
| # Verificar límite de dispositivos | |
| max_devices = 3 if current_user.is_premium else 1 | |
| if len(current_user.devices) >= max_devices: | |
| if not device_data.confirm_override: | |
| logger.warning(f"Límite de dispositivos alcanzado para {current_user.username}") | |
| raise HTTPException( | |
| status_code=403, | |
| detail="device_override_required" | |
| ) | |
| else: | |
| if current_user.is_premium: | |
| # Para premium, eliminamos el dispositivo más antiguo para liberar un espacio | |
| oldest_device = db.query(Device).filter(Device.user_id == current_user.id).order_by(Device.last_active.asc()).first() | |
| if oldest_device: | |
| logger.info(f"Eliminando dispositivo antiguo {oldest_device.name} para usuario premium {current_user.username}") | |
| db.delete(oldest_device) | |
| else: | |
| # Para no premium, eliminamos todos | |
| db.query(Device).filter(Device.user_id == current_user.id).delete() | |
| db.commit() | |
| # Recargar relaciones | |
| db.refresh(current_user) | |
| logger.info(f"Anulando sesión anterior para {current_user.username} en registro de dispositivo") | |
| new_device = Device( | |
| user_id=current_user.id, | |
| device_id=device_data.device_id, | |
| name=device_data.device_name | |
| ) | |
| db.add(new_device) | |
| logger.info(f"Nuevo dispositivo registrado: {device_data.device_name} ({device_data.device_id})") | |
| db.commit() | |
| # Encolar proactivamente stems en la nube para descarga automática en el nuevo dispositivo | |
| _enqueue_cloud_stems_for_new_device(db, current_user, device_data.device_id) | |
| return {"message": "Dispositivo registrado exitosamente"} | |
| def forgot_password( | |
| data: ForgotPasswordRequest, | |
| background_tasks: BackgroundTasks, | |
| db: Session = Depends(get_db) | |
| ): | |
| import random | |
| user = db.query(User).filter(User.email == data.email).first() | |
| if not user: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="No se encontró ninguna cuenta registrada con ese correo electrónico" | |
| ) | |
| code = f"{random.randint(100000, 999999)}" | |
| user.reset_token = code | |
| user.reset_token_expires = datetime.utcnow() + timedelta(minutes=15) | |
| db.commit() | |
| from app.utils.email import send_recovery_email | |
| background_tasks.add_task(send_recovery_email, user.email, code) | |
| return {"message": "Código de verificación enviado exitosamente a tu correo electrónico"} | |
| def reset_password( | |
| data: ResetPasswordRequest, | |
| db: Session = Depends(get_db) | |
| ): | |
| user = db.query(User).filter(User.email == data.email).first() | |
| if not user: | |
| raise HTTPException(status_code=404, detail="Usuario no encontrado") | |
| if not user.reset_token or user.reset_token != data.code: | |
| raise HTTPException(status_code=400, detail="Código de verificación incorrecto") | |
| if not user.reset_token_expires or user.reset_token_expires < datetime.utcnow(): | |
| raise HTTPException(status_code=400, detail="El código de verificación ha expirado") | |
| user.hashed_password = get_password_hash(data.password) | |
| user.reset_token = None | |
| user.reset_token_expires = None | |
| db.commit() | |
| return {"message": "Contraseña restablecida exitosamente"} | |
| def change_user_password( | |
| data: ChangePasswordRequest, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| if not current_user.hashed_password: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Esta cuenta está vinculada a un proveedor externo (Google/Supabase) y no tiene una contraseña local" | |
| ) | |
| if not verify_password(data.old_password, current_user.hashed_password): | |
| raise HTTPException(status_code=400, detail="La contraseña actual es incorrecta") | |
| current_user.hashed_password = get_password_hash(data.new_password) | |
| db.commit() | |
| return {"message": "Contraseña actualizada exitosamente"} | |
| def update_user_avatar( | |
| data: AvatarUpdate, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| current_user.avatar_url = data.avatar_url | |
| db.commit() | |
| return {"avatar_url": current_user.avatar_url} | |
| def update_username( | |
| data: UsernameUpdate, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| new_username = data.username.strip() | |
| if not new_username: | |
| raise HTTPException(status_code=400, detail="El nombre de usuario no puede estar vacío") | |
| if len(new_username) < 3 or len(new_username) > 20: | |
| raise HTTPException(status_code=400, detail="El nombre de usuario debe tener entre 3 y 20 caracteres") | |
| import re | |
| if not re.match(r"^[a-zA-Z0-9.\-\s]+$", new_username): | |
| raise HTTPException(status_code=400, detail="El nombre de usuario solo puede contener letras, números, espacios, puntos y guiones") | |
| # Excluir caracteres especiales raros si es necesario, pero validemos similitud básica | |
| if new_username == current_user.username: | |
| return { | |
| "username": current_user.username, | |
| "username_changes_this_month": current_user.username_changes_this_month, | |
| "message": "El nombre de usuario es idéntico" | |
| } | |
| # Reset del contador si es un nuevo mes | |
| now = datetime.utcnow() | |
| if current_user.last_username_change_at: | |
| last_change = current_user.last_username_change_at | |
| if (now.year > last_change.year) or (now.year == last_change.year and now.month > last_change.month): | |
| current_user.username_changes_this_month = 0 | |
| db.commit() | |
| # Verificar límite | |
| if current_user.username_changes_this_month >= 3: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Has alcanzado el límite de 3 cambios de nombre de usuario este mes. Podrás cambiarlo nuevamente el 1 del próximo mes." | |
| ) | |
| # Verificar si ya existe | |
| exists = db.query(User).filter(User.username == new_username).first() | |
| if exists: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="El nombre de usuario ya está registrado por otra cuenta" | |
| ) | |
| # Proceder a actualizar | |
| current_user.username = new_username | |
| current_user.username_changes_this_month += 1 | |
| current_user.last_username_change_at = now | |
| db.commit() | |
| # Notificar al usuario por WebSocket en tiempo real en todos sus dispositivos | |
| try: | |
| from app.websocket_manager import send_sync_message | |
| send_sync_message({ | |
| "type": "username_updated", | |
| "username": new_username | |
| }, current_user.id) | |
| except Exception: | |
| pass | |
| logger.info(f"Usuario {current_user.id} cambió su username a: {new_username}. Cambios este mes: {current_user.username_changes_this_month}/3") | |
| return { | |
| "username": current_user.username, | |
| "username_changes_this_month": current_user.username_changes_this_month, | |
| "last_username_change_at": current_user.last_username_change_at | |
| } | |
| def test_smtp(to: str): | |
| """ | |
| Endpoint de diagnóstico para probar el envío de correos SMTP y ver el error de configuración o conexión. | |
| """ | |
| from app.utils.email import send_recovery_email | |
| import os | |
| # Obtener valores para mostrarlos (con password censurado) | |
| smtp_host = os.getenv("SMTP_HOST") | |
| smtp_port = os.getenv("SMTP_PORT") | |
| smtp_user = os.getenv("SMTP_USER") | |
| smtp_password = os.getenv("SMTP_PASSWORD") | |
| smtp_from = os.getenv("SMTP_FROM") | |
| # Limpiar comillas iniciales/finales y espacios | |
| if smtp_host: smtp_host = smtp_host.strip("'\" ") | |
| if smtp_port: smtp_port = smtp_port.strip("'\" ") | |
| if smtp_user: smtp_user = smtp_user.strip("'\" ") | |
| if smtp_password: smtp_password = smtp_password.strip("'\" ") | |
| if smtp_from: smtp_from = smtp_from.strip("'\" ") | |
| config_info = { | |
| "SMTP_HOST": smtp_host, | |
| "SMTP_PORT": smtp_port, | |
| "SMTP_USER": smtp_user, | |
| "SMTP_FROM": smtp_from or f"Melodix <{smtp_user}>", | |
| "SMTP_PASSWORD_SET": smtp_password is not None and len(smtp_password) > 0 | |
| } | |
| # Intentar enviar | |
| try: | |
| success = send_recovery_email(to, "999999", raise_on_error=True) | |
| if success: | |
| return { | |
| "status": "success", | |
| "message": f"Correo de prueba enviado con éxito a {to}", | |
| "config": config_info | |
| } | |
| else: | |
| return { | |
| "status": "failed", | |
| "message": "El método send_recovery_email retornó False. Revisa los logs de tu servidor en Render para ver el error exacto.", | |
| "config": config_info | |
| } | |
| except Exception as e: | |
| import traceback | |
| error_trace = traceback.format_exc() | |
| return { | |
| "status": "error", | |
| "message": str(e), | |
| "traceback": error_trace, | |
| "config": config_info | |
| } | |