feat(mail): backend Outlook COM (Windows local) + toggle dans Settings
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.
This commit is contained in:
parent
bfd91634bb
commit
97ac7681d2
@ -79,6 +79,8 @@ SECTIONS = {
|
|||||||
("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False),
|
("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False),
|
||||||
],
|
],
|
||||||
"smtp": [
|
"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_host", "Serveur SMTP (ex: vpdsismtp1.sanef.groupe)", False),
|
||||||
("smtp_port", "Port SMTP (25 / 465 / 587)", False),
|
("smtp_port", "Port SMTP (25 / 465 / 587)", False),
|
||||||
("smtp_user", "User SMTP (vide si relay anonyme)", False),
|
("smtp_user", "User SMTP (vide si relay anonyme)", False),
|
||||||
|
|||||||
73
app/services/mail_outlook_com.py
Normal file
73
app/services/mail_outlook_com.py
Normal file
@ -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
|
||||||
@ -45,10 +45,8 @@ def get_smtp_config(db):
|
|||||||
|
|
||||||
def send_html_mail(db, to: Iterable[str], subject: str, html: str,
|
def send_html_mail(db, to: Iterable[str], subject: str, html: str,
|
||||||
cc: Iterable[str] = None, dry_run: bool = False) -> dict:
|
cc: Iterable[str] = None, dry_run: bool = False) -> dict:
|
||||||
"""Envoie un mail HTML.
|
"""Envoie un mail HTML via le backend configuré (Settings > SMTP > mail_backend).
|
||||||
- to : liste d'adresses (ou string seul)
|
Backends : 'smtp' (défaut, serveur) | 'outlook_com' (Windows local via Outlook).
|
||||||
- dry_run : si True, ne tente pas l'envoi (juste renvoie le rendu)
|
|
||||||
Retourne {ok, msg, sent_to, html_preview, subject_preview, error_kind}.
|
|
||||||
"""
|
"""
|
||||||
if isinstance(to, str):
|
if isinstance(to, str):
|
||||||
to = [to]
|
to = [to]
|
||||||
@ -56,6 +54,19 @@ def send_html_mail(db, to: Iterable[str], subject: str, html: str,
|
|||||||
if not to:
|
if not to:
|
||||||
return {"ok": False, "msg": "Destinataire(s) vide(s)"}
|
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)
|
cfg = get_smtp_config(db)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
return {"ok": False, "msg": "Config SMTP incomplète (Settings > SMTP)",
|
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:
|
if dry_run:
|
||||||
return {"ok": True, "msg": "Dry-run — pas d'envoi",
|
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 = MIMEMultipart("alternative")
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
|
|||||||
@ -665,12 +665,30 @@
|
|||||||
<!-- SMTP (envoi de mails — prévenance PCT, etc.) -->
|
<!-- SMTP (envoi de mails — prévenance PCT, etc.) -->
|
||||||
{% if visible.smtp %}
|
{% if visible.smtp %}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
{{ section_header("smtp", "SMTP — Envoi de mails (Office 365)", "Mail", "badge-blue") }}
|
{{ section_header("smtp", "Mail — Envoi (SMTP / Outlook COM)", "Mail", "badge-blue") }}
|
||||||
<div x-show="open === 'smtp'" class="border-t border-cyber-border p-4">
|
<div x-show="open === 'smtp'" class="border-t border-cyber-border p-4">
|
||||||
<form method="POST" action="/settings/smtp" class="space-y-3">
|
<form method="POST" action="/settings/smtp" class="space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-3 p-3 bg-cyber-border/10 rounded">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-cyber-accent font-bold">Backend mail</label>
|
||||||
|
<select name="mail_backend" class="w-full" {% if not editable.smtp %}disabled{% endif %}>
|
||||||
|
<option value="smtp" {% if (vals.mail_backend or 'smtp') == 'smtp' %}selected{% endif %}>SMTP (serveur Linux/prod)</option>
|
||||||
|
<option value="outlook_com" {% if vals.mail_backend == 'outlook_com' %}selected{% endif %}>Outlook COM (poste Windows local)</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Si tu testes sur ton poste Windows et que SMTP est filtré → choisis Outlook COM.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-cyber-accent font-bold">Outlook COM : mode</label>
|
||||||
|
<select name="mail_outlook_display_only" class="w-full" {% if not editable.smtp %}disabled{% endif %}>
|
||||||
|
<option value="false" {% if (vals.mail_outlook_display_only or 'false') != 'true' %}selected{% endif %}>Envoi automatique (Send)</option>
|
||||||
|
<option value="true" {% if vals.mail_outlook_display_only == 'true' %}selected{% endif %}>Ouvrir Outlook (Display) — relecture manuelle</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">"Display" ouvre une fenêtre Outlook prérémplie ; tu cliques Envoyer toi-même.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-gray-500">Serveur SMTP</label>
|
<label class="text-xs text-gray-500">Serveur SMTP <span class="text-gray-600">(ignoré si Outlook COM)</span></label>
|
||||||
<input type="text" name="smtp_host" value="{{ vals.smtp_host }}" placeholder="smtp.office365.com" class="w-full font-mono text-xs" {% if not editable.smtp %}disabled{% endif %}>
|
<input type="text" name="smtp_host" value="{{ vals.smtp_host }}" placeholder="smtp.office365.com" class="w-full font-mono text-xs" {% if not editable.smtp %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user