From 97ac7681d2a73ef4621b669d4c3ffb34f7b44017 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Thu, 7 May 2026 22:24:46 +0200 Subject: [PATCH] feat(mail): backend Outlook COM (Windows local) + toggle dans Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resout le probleme de firewall SMTP corporate qui bloque outbound 25/465/587 depuis les postes de travail. Outlook utilise HTTPS (EWS/Graph) vers O365 -> aucun firewall. Service mail_outlook_com.py: - send_via_outlook_com(to, subject, html, cc, display_only): pilote Outlook via COM pywin32. Mail apparait dans Sent Items du user, traçable. - Mode display_only=True ouvre la fenetre de composition Outlook au lieu d'envoyer automatiquement (relecture manuelle). - pythoncom.CoInitialize() pour fonctionner dans un thread uvicorn. Service mail_service.py: - send_html_mail dispatche selon Setting 'mail_backend' (smtp / outlook_com). - Defaut = smtp. Settings: - 'mail_backend' = 'smtp' | 'outlook_com' - 'mail_outlook_display_only' = 'true' | 'false' - UI: 2 selects en haut de la card SMTP avec hints + le SMTP existant en dessous Pre-requis Windows: pip install pywin32 + Outlook lance + connecte au compte O365. --- app/routers/settings.py | 2 + app/services/mail_outlook_com.py | 73 ++++++++++++++++++++++++++++++++ app/services/mail_service.py | 22 +++++++--- app/templates/settings.html | 22 +++++++++- 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 app/services/mail_outlook_com.py diff --git a/app/routers/settings.py b/app/routers/settings.py index cab231f..23729f3 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -79,6 +79,8 @@ SECTIONS = { ("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False), ], "smtp": [ + ("mail_backend", "Backend mail : 'smtp' (serveur) ou 'outlook_com' (Windows local via Outlook)", False), + ("mail_outlook_display_only", "Outlook COM : ouvrir compose au lieu d'envoyer auto (true/false)", False), ("smtp_host", "Serveur SMTP (ex: vpdsismtp1.sanef.groupe)", False), ("smtp_port", "Port SMTP (25 / 465 / 587)", False), ("smtp_user", "User SMTP (vide si relay anonyme)", False), diff --git a/app/services/mail_outlook_com.py b/app/services/mail_outlook_com.py new file mode 100644 index 0000000..a9da67b --- /dev/null +++ b/app/services/mail_outlook_com.py @@ -0,0 +1,73 @@ +"""Backend mail via Outlook COM (pywin32) — Windows local uniquement. + +Permet d'envoyer un mail via la session Outlook ouverte sur le poste qui fait +tourner PatchCenter. Aucun firewall SMTP à ouvrir : Outlook utilise HTTPS +(EWS/Graph) pour parler à O365. + +Usage : nécessite Outlook lancé + connecté + pywin32 installé. + +Mode `display_only=True` : ouvre la fenêtre de composition pré-remplie au +lieu d'envoyer automatiquement (utile pour relecture finale). +""" +import logging + +log = logging.getLogger("patchcenter.mail.outlook_com") + + +def send_via_outlook_com(to, subject, html, cc=None, display_only=False): + """Envoie un mail HTML via Outlook COM. Retourne {ok, msg, sent_to}.""" + try: + import win32com.client # pywin32 + import pythoncom # pour CoInitialize en thread non-main + except ImportError as e: + return {"ok": False, + "msg": f"pywin32 non installé sur le serveur PatchCenter ({e}). " + f"`pip install pywin32` dans le venv puis restart uvicorn."} + + if isinstance(to, str): + to = [to] + to = [a.strip() for a in to if a and a.strip()] + if not to: + return {"ok": False, "msg": "Destinataire(s) vide(s)"} + if cc is None: + cc = [] + if isinstance(cc, str): + cc = [cc] + cc = [a.strip() for a in cc if a and a.strip()] + + # CoInitialize est nécessaire dans un thread (uvicorn worker = thread non-main) + try: + pythoncom.CoInitialize() + except Exception: + pass + + try: + outlook = win32com.client.Dispatch("Outlook.Application") + mail = outlook.CreateItem(0) # 0 = olMailItem + mail.To = "; ".join(to) + if cc: + mail.CC = "; ".join(cc) + mail.Subject = subject + mail.HTMLBody = html + if display_only: + mail.Display(False) # False = non-modal (ouvre dans une fenêtre) + return {"ok": True, + "msg": f"Mail ouvert dans Outlook pour relecture (clique Envoyer dans la fenêtre)", + "sent_to": to, "cc": cc, "backend": "outlook_com", + "mode": "display"} + mail.Send() + return {"ok": True, + "msg": f"Envoyé via Outlook à {', '.join(to)}" + (f" + CC {len(cc)}" if cc else ""), + "sent_to": to, "cc": cc, "backend": "outlook_com", "mode": "send"} + except Exception as e: + # Erreurs courantes : + # - "Aucune instance Outlook" : Outlook pas lancé + # - "Operation aborted" : prompt sécurité refusé par utilisateur + return {"ok": False, + "msg": f"Outlook COM: {type(e).__name__}: {e}", + "backend": "outlook_com"} + finally: + try: + pythoncom.CoUninitialize() + except Exception: + pass diff --git a/app/services/mail_service.py b/app/services/mail_service.py index 659bc55..a3d7380 100644 --- a/app/services/mail_service.py +++ b/app/services/mail_service.py @@ -45,10 +45,8 @@ def get_smtp_config(db): def send_html_mail(db, to: Iterable[str], subject: str, html: str, cc: Iterable[str] = None, dry_run: bool = False) -> dict: - """Envoie un mail HTML. - - to : liste d'adresses (ou string seul) - - dry_run : si True, ne tente pas l'envoi (juste renvoie le rendu) - Retourne {ok, msg, sent_to, html_preview, subject_preview, error_kind}. + """Envoie un mail HTML via le backend configuré (Settings > SMTP > mail_backend). + Backends : 'smtp' (défaut, serveur) | 'outlook_com' (Windows local via Outlook). """ if isinstance(to, str): to = [to] @@ -56,6 +54,19 @@ def send_html_mail(db, to: Iterable[str], subject: str, html: str, if not to: return {"ok": False, "msg": "Destinataire(s) vide(s)"} + backend = (_get(db, "mail_backend", "smtp") or "smtp").lower() + + # ─── Backend Outlook COM (Windows local) ───────────────────── + if backend == "outlook_com": + if dry_run: + return {"ok": True, "msg": "Dry-run — pas d'envoi", + "html_preview": html, "subject_preview": subject, "sent_to": to, + "backend": "outlook_com"} + display_only = _get(db, "mail_outlook_display_only", "false").lower() in ("true", "1", "yes") + from .mail_outlook_com import send_via_outlook_com + return send_via_outlook_com(to, subject, html, cc=cc, display_only=display_only) + + # ─── Backend SMTP (défaut, serveur) ─────────────────────────── cfg = get_smtp_config(db) if not cfg: return {"ok": False, "msg": "Config SMTP incomplète (Settings > SMTP)", @@ -64,7 +75,8 @@ def send_html_mail(db, to: Iterable[str], subject: str, html: str, if dry_run: return {"ok": True, "msg": "Dry-run — pas d'envoi", - "html_preview": html, "subject_preview": subject, "sent_to": to} + "html_preview": html, "subject_preview": subject, "sent_to": to, + "backend": "smtp"} msg = MIMEMultipart("alternative") msg["Subject"] = subject diff --git a/app/templates/settings.html b/app/templates/settings.html index 0f050c4..ccf36b0 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -665,12 +665,30 @@ {% if visible.smtp %}
- {{ section_header("smtp", "SMTP — Envoi de mails (Office 365)", "Mail", "badge-blue") }} + {{ section_header("smtp", "Mail — Envoi (SMTP / Outlook COM)", "Mail", "badge-blue") }}
+
+
+ + +

Si tu testes sur ton poste Windows et que SMTP est filtré → choisis Outlook COM.

+
+
+ + +

"Display" ouvre une fenêtre Outlook prérémplie ; tu cliques Envoyer toi-même.

+
+
- +