- Extraccion de helpers en backend: audit_helpers.py, helpers.py - Modularizacion de schemas en archivos individuales por dominio - Reduccion de audit.py en 953 lineas (74% del archivo) - Reduccion de tickets.py en 655 lineas (60% del archivo) - Expansion de auth.py con recuperacion de contrasenia y tokens - Nuevos modulos: core/email.py, core/cache.py - Reorganizacion de scripts a backend/scripts/ - Frontend: refactorizacion de audit page con array-driven components - Frontend: correccion de 11 errores ortograficos en tickets page - Frontend: proxy Docker corregido en vite.config.js - Frontend: nuevas rutas forgot-password, reset-password, organization, profile - Nuevas utilidades TS: colorUtils.ts, dateFormats.ts - 5 nuevos archivos de tests unitarios en backend/tests/unit/ - Eliminacion de 3 scripts temporales de prueba - Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
"""
|
|
Email Utility - ServiceManagerWeb
|
|
|
|
Envío directo de emails desde el backend para flujos críticos
|
|
(reseteo de contraseña, verificación) sin depender de Celery.
|
|
"""
|
|
|
|
import asyncio
|
|
import smtplib
|
|
import ssl
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from typing import Optional
|
|
import structlog
|
|
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
def _send_smtp_sync(
|
|
to_email: str,
|
|
subject: str,
|
|
html_content: str,
|
|
text_content: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Enviar email de forma síncrona vía SMTP.
|
|
Llamar desde asyncio.to_thread para no bloquear el event loop.
|
|
"""
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{settings.DEFAULT_FROM_NAME} <{settings.DEFAULT_FROM_EMAIL}>"
|
|
msg["To"] = to_email
|
|
|
|
if text_content:
|
|
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
|
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
|
|
|
if settings.SMTP_USE_SSL:
|
|
context = ssl.create_default_context()
|
|
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=context) as server:
|
|
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
|
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
|
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
|
else:
|
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
|
if settings.SMTP_USE_TLS:
|
|
server.starttls()
|
|
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
|
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
|
server.sendmail(settings.DEFAULT_FROM_EMAIL, to_email, msg.as_string())
|
|
|
|
|
|
async def send_email(
|
|
to_email: str,
|
|
subject: str,
|
|
html_content: str,
|
|
text_content: Optional[str] = None,
|
|
) -> bool:
|
|
"""
|
|
Enviar email de forma asíncrona.
|
|
|
|
Retorna True si el envío fue exitoso, False con log de error si falló.
|
|
Se diseña para no propagar excepciones (fail-silent) en flujos de UI.
|
|
"""
|
|
try:
|
|
await asyncio.to_thread(
|
|
_send_smtp_sync,
|
|
to_email,
|
|
subject,
|
|
html_content,
|
|
text_content,
|
|
)
|
|
logger.info("Email sent", to=to_email, subject=subject)
|
|
return True
|
|
except Exception as exc:
|
|
logger.error("Email send failed", to=to_email, subject=subject, error=str(exc))
|
|
return False
|
|
|
|
|
|
# ============================================================
|
|
# Plantillas HTML inline
|
|
# ============================================================
|
|
|
|
def build_password_reset_email(reset_url: str, user_name: str) -> tuple[str, str]:
|
|
"""
|
|
Construir HTML y texto plano para email de reseteo de contraseña.
|
|
|
|
Returns:
|
|
(html_content, text_content)
|
|
"""
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Restablecer contraseña</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:40px 0;">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background:#1d4ed8;padding:32px 40px;text-align:center;">
|
|
<span style="color:#ffffff;font-size:22px;font-weight:700;letter-spacing:-.5px;">ServiceManager</span>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Body -->
|
|
<tr>
|
|
<td style="padding:40px;">
|
|
<h2 style="margin:0 0 16px;font-size:20px;color:#111827;">Restablece tu contraseña</h2>
|
|
<p style="margin:0 0 12px;font-size:15px;color:#374151;line-height:1.6;">
|
|
Hola <strong>{user_name}</strong>,
|
|
</p>
|
|
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">
|
|
Recibimos una solicitud para restablecer la contraseña de tu cuenta.
|
|
Haz clic en el botón de abajo para crear una nueva contraseña.
|
|
Este enlace es válido por <strong>30 minutos</strong>.
|
|
</p>
|
|
|
|
<table cellpadding="0" cellspacing="0" style="margin:0 auto 32px;">
|
|
<tr>
|
|
<td style="background:#1d4ed8;border-radius:6px;">
|
|
<a href="{reset_url}"
|
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
|
Restablecer contraseña
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;">
|
|
Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:
|
|
</p>
|
|
<p style="margin:0 0 24px;font-size:12px;color:#2563eb;word-break:break-all;">
|
|
<a href="{reset_url}" style="color:#2563eb;">{reset_url}</a>
|
|
</p>
|
|
|
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
|
|
|
|
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.6;">
|
|
Si no solicitaste restablecer tu contraseña, puedes ignorar este mensaje.
|
|
Tu contraseña no se modificará.<br>
|
|
Por seguridad, este enlace expira en 30 minutos y solo puede usarse una vez.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding:20px 40px;background:#f9fafb;text-align:center;">
|
|
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
|
© 2026 Aduanasoft — Acceso exclusivo autorizado
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
text = (
|
|
f"Hola {user_name},\n\n"
|
|
"Recibimos una solicitud para restablecer la contraseña de tu cuenta.\n\n"
|
|
f"Haz clic en el siguiente enlace (válido por 30 minutos):\n{reset_url}\n\n"
|
|
"Si no solicitaste este cambio, ignora este mensaje.\n\n"
|
|
"— ServiceManager"
|
|
)
|
|
|
|
return html, text
|