feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad
- 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
This commit is contained in:
179
backend/app/core/email.py
Normal file
179
backend/app/core/email.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user