se responde al remitente con las plantillas de aceptado/rechazado

El sistema contesta a quien envió el correo con la plantilla que corresponda.
Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el
asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta
al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo.

- respuesta_proveedor.py: contextos de las plantillas, envío y protecciones
  (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes
  automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h).
- observaciones.py: motivos de rechazo con título, filas y cómo corregir
  (borrador de textos); también para correos sin comprobante válido.
- Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y
  ya no tumba el ciclo; "guía no encontrada" exige que HANA responda.
- outlook_utilities: es_mensaje_automatico y copia oculta en send_email.
- seguimiento: notificacion_destinatario (con migración) y
  contar_respuestas_correo.
- config_validator anuncia el modo activo; documentación y pruebas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent af9e3961e5
commit 4deb9d4e4d
22 files changed
+1660 -146

No files matched your search

+29 -4
View File
@@ -52,11 +52,30 @@ def get_access_token() -> str:
correlation_id = result.get("correlation_id")
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
# ============================================================================
def es_mensaje_automatico(headers: list[dict] | None) -> bool:
"""
Indica si un mensaje fue generado automáticamente (respuesta automática,
rebote, lista de distribución…) a partir de ``internetMessageHeaders``.
No se debe responder a estos mensajes: se generarían bucles de correos.
"""
h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []}
if h.get("auto-submitted", "no") not in ("", "no"):
return True
if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h:
return True
if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"):
return True
return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega
# ============================================================================
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
"""
Obtiene los correos de una cuenta específica en un rango de fechas.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach
y es_automatico (True si es una respuesta automática o un rebote).
"""
headers = {
"Authorization": f"Bearer {token}",
@@ -73,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
params = {
"$filter": filter_query,
"$select": "id,receivedDateTime,hasAttachments,subject,from",
"$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
"$top": 50
}
@@ -107,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
"sender": sender,
"subject": msg.get("subject", ""),
"has_attach": msg.get("hasAttachments")
"has_attach": msg.get("hasAttachments"),
"es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")),
})
url = data.get("@odata.nextLink")
@@ -153,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
return downloaded_files
# ============================================================================
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None) -> bool:
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool:
"""
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
@@ -164,6 +184,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
:param html_body: Contenido del correo en formato HTML.
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
:param cc_recipients: Lista de correos electrónicos en copia (CC).
:param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios.
:return: True si se envió correctamente, False en caso contrario.
"""
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
@@ -192,6 +213,9 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
}
})
# Construir lista de destinatarios en copia oculta (BCC) para Graph API
bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])]
# Construir lista de adjuntos
attachments_payload = []
if attachment_paths:
@@ -229,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
},
"toRecipients": recipients_payload,
"ccRecipients": cc_payload,
"bccRecipients": bcc_payload,
"attachments": attachments_payload
},
"saveToSentItems": "true"