feat(pct): bouton Prevenance PCT + preview avant envoi + CC responsables/referents
- Service mail_service.py: send_html_mail via SMTP standard (host/port/user/pass/from/use_tls
depuis Settings > SMTP). Gere SSL_465 et STARTTLS_587. Mode dry_run pour preview.
- Settings: nouvelle section 'smtp' avec smtp_host/port/user/pass/from/use_tls/pct_recipient
(a configurer pour O365 SMTP submission)
- Router planning_import.py:
* _build_pct_email(): construit subject + HTML pro/colore (header bleu degrade SANEF,
cards avec border-left bleu/orange, tableau serveurs, footer)
* Subject: 'Intervention sur <app>' si app uniforme, sinon liste des serveurs
* Plage horaire = 20 min × N serveurs (formattee Hh MM)
* 'Moyen d'exploitation prevu : Rollback en cas de probleme' ajoute en bas
* _fetch_pct_cc_emails(): query distinct contacts depuis responsable_domaine_contact_id
+ referent_technique_contact_id + server_additional_referents
* Endpoint POST /patching/import/pct-prevenance/preview retourne {subject, html, to, cc,
smtp_configured, row_count} sans envoyer
* Endpoint POST /patching/import/pct-prevenance/send envoie reellement, audit log,
update pct_mail_sent_at sur les rows
- Template patching_import.html:
* Bouton 'Prevenance PCT' (violet) a cote des autres actions
* Modal preview avec iframe sandboxe pour le rendu HTML mail
* Affiche destinataires, CC, objet, count serveurs
* Warning rouge si SMTP non configure (envoi desactive, preview seulement)
* 2 boutons: Annuler / Envoyer (avec confirmation)
This commit is contained in:
parent
b2f8456b03
commit
00998e9320
@ -922,7 +922,347 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)):
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
def _common_iexec_row_check(row_id: int, db, user, perms):
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
# Prévenance PCT — envoi d'un mail à PCT.reims@sanef.com pour annoncer
|
||||
# une intervention sur un ou plusieurs serveurs sélectionnés.
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_pct_email(rows, intervenant_name=""):
|
||||
"""Construit (subject, html_body) pour la prévenance PCT à partir de rows.
|
||||
rows = list de tuples (asset_name, application_name, jour, heure, environnement, domaine).
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
# Détermine le sujet
|
||||
apps = [r["application_name"] for r in rows if r.get("application_name")]
|
||||
distinct_apps = sorted(set(a.strip() for a in apps if a and a.strip()))
|
||||
server_names = [r["asset_name"] for r in rows if r.get("asset_name")]
|
||||
|
||||
if len(distinct_apps) == 1:
|
||||
target_label = distinct_apps[0]
|
||||
elif len(distinct_apps) > 1:
|
||||
target_label = ", ".join(distinct_apps)
|
||||
elif server_names:
|
||||
target_label = ", ".join(server_names)
|
||||
else:
|
||||
target_label = "(serveurs non identifiés)"
|
||||
|
||||
subject = f"Intervention sur {target_label}"
|
||||
|
||||
# Plage horaire = 20 min × N serveurs
|
||||
n = len(rows)
|
||||
plage_min = n * 20
|
||||
h = plage_min // 60
|
||||
m = plage_min % 60
|
||||
if h and m:
|
||||
plage_str = f"{h}h{m:02d} (≈ {n} serveur(s) × 20 min)"
|
||||
elif h:
|
||||
plage_str = f"{h}h00 (≈ {n} serveur(s) × 20 min)"
|
||||
else:
|
||||
plage_str = f"{m} min (≈ {n} serveur(s) × 20 min)"
|
||||
|
||||
# Liste serveurs avec date/heure
|
||||
def _fmt_dh(jour, heure):
|
||||
if not jour and not heure:
|
||||
return "(non planifié)"
|
||||
d_str = ""
|
||||
if jour:
|
||||
try:
|
||||
d_str = jour.strftime("%d/%m/%Y") if hasattr(jour, "strftime") else str(jour)
|
||||
except Exception:
|
||||
d_str = str(jour)
|
||||
h_str = ""
|
||||
if heure:
|
||||
try:
|
||||
h_str = heure.strftime("%H:%M") if hasattr(heure, "strftime") else str(heure)
|
||||
except Exception:
|
||||
h_str = str(heure)
|
||||
return (d_str + (" à " + h_str if h_str else "")).strip() or "(non planifié)"
|
||||
|
||||
serv_table_rows = ""
|
||||
for r in rows:
|
||||
srv = r.get("asset_name") or "?"
|
||||
dh = _fmt_dh(r.get("jour"), r.get("heure_t"))
|
||||
env = r.get("environnement") or ""
|
||||
app = r.get("application_name") or ""
|
||||
serv_table_rows += (
|
||||
f'<tr style="border-bottom:1px solid #e5e7eb;">'
|
||||
f'<td style="padding:10px 14px;font-family:monospace;color:#1e3a8a;">{srv}</td>'
|
||||
f'<td style="padding:10px 14px;color:#374151;">{app}</td>'
|
||||
f'<td style="padding:10px 14px;color:#6b7280;">{env}</td>'
|
||||
f'<td style="padding:10px 14px;color:#374151;">{dh}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
localisations = ", ".join(server_names) if server_names else "(non identifié)"
|
||||
equip_service = ", ".join(distinct_apps) if distinct_apps else "(non identifié)"
|
||||
|
||||
# Body HTML pro/coloré (compatible Outlook : tout en inline CSS)
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="fr"><head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#f3f4f6;font-family:'Segoe UI',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f4f6;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="720" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr><td style="background:linear-gradient(90deg,#1e3a8a 0%,#1e40af 100%);padding:24px 32px;color:#ffffff;">
|
||||
<div style="font-size:11px;letter-spacing:0.15em;text-transform:uppercase;opacity:0.85;">SANEF — SecOps</div>
|
||||
<h1 style="margin:6px 0 0;font-size:22px;font-weight:600;letter-spacing:-0.01em;">Prévenance d'intervention PCT</h1>
|
||||
</td></tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr><td style="padding:32px;color:#1f2937;font-size:14px;line-height:1.6;">
|
||||
<p style="margin:0 0 16px;">Bonjour,</p>
|
||||
<p style="margin:0 0 24px;">
|
||||
Dans le cadre des corrections de vulnérabilités du SI SANEF, une intervention est prévue sur
|
||||
<strong style="color:#1e3a8a;">{target_label}</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Card infos -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||
style="border-left:4px solid #2563eb;background:#eff6ff;border-radius:4px;margin:0 0 24px;">
|
||||
<tr><td style="padding:16px 20px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-weight:600;width:40%;">Nature de l'intervention :</td>
|
||||
<td style="padding:4px 0;color:#1f2937;">Mise à jour du serveur plus redémarrage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Type d'intervention :</td>
|
||||
<td style="padding:4px 0;color:#1f2937;">Correctif</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Localisation :</td>
|
||||
<td style="padding:4px 0;color:#1f2937;font-family:monospace;">{localisations}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Équipement / service :</td>
|
||||
<td style="padding:4px 0;color:#1f2937;">{equip_service}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-weight:600;">Plage horaire estimée :</td>
|
||||
<td style="padding:4px 0;color:#1f2937;"><strong>{plage_str}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Tableau serveurs -->
|
||||
<h3 style="margin:24px 0 12px;font-size:13px;letter-spacing:0.08em;text-transform:uppercase;color:#374151;">
|
||||
Détail des interventions ({n} serveur{'s' if n>1 else ''})
|
||||
</h3>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||
style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:4px;overflow:hidden;font-size:13px;">
|
||||
<thead><tr style="background:#f9fafb;border-bottom:2px solid #e5e7eb;">
|
||||
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Serveur</th>
|
||||
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Application</th>
|
||||
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Env.</th>
|
||||
<th align="left" style="padding:10px 14px;color:#374151;font-weight:600;">Date / Heure</th>
|
||||
</tr></thead>
|
||||
<tbody>{serv_table_rows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Impact -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||
style="border-left:4px solid #f59e0b;background:#fffbeb;border-radius:4px;margin:24px 0 0;">
|
||||
<tr><td style="padding:14px 20px;color:#92400e;font-size:13px;">
|
||||
<strong>Impact :</strong> interruption d'environ <strong>5 minutes par serveur</strong> au moment du redémarrage.
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin:16px 0 0;">
|
||||
<tr>
|
||||
<td style="padding:6px 0;color:#6b7280;font-weight:600;width:50%;">Moyen d'exploitation à prévoir :</td>
|
||||
<td style="padding:6px 0;color:#1f2937;">Aucun</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;color:#6b7280;font-weight:600;">Upgrade réalisé par :</td>
|
||||
<td style="padding:6px 0;color:#1f2937;">{intervenant_name or 'SecOps'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;color:#6b7280;font-weight:600;">Moyen d'exploitation prévu :</td>
|
||||
<td style="padding:6px 0;color:#1f2937;"><strong>Rollback en cas de problème</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">
|
||||
Cordialement,<br>
|
||||
L'équipe SecOps SANEF
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="background:#f9fafb;padding:14px 32px;border-top:1px solid #e5e7eb;color:#9ca3af;font-size:11px;">
|
||||
Mail généré automatiquement par <strong>PatchCenter</strong>. Pour toute question : équipe SecOps.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
return subject, html
|
||||
|
||||
|
||||
def _fetch_pct_cc_emails(db, row_ids):
|
||||
"""Récupère les emails distincts (responsable domaine + référent technique
|
||||
+ référents additionnels) pour les rows sélectionnées, à mettre en CC du mail PCT."""
|
||||
if not row_ids:
|
||||
return []
|
||||
placeholders = ",".join(str(i) for i in row_ids)
|
||||
contact_ids_rows = db.execute(text(f"""
|
||||
SELECT DISTINCT contact_id FROM (
|
||||
SELECT s.responsable_domaine_contact_id AS contact_id
|
||||
FROM patch_planning_import_rows r
|
||||
JOIN servers s ON s.id = r.server_id
|
||||
WHERE r.id IN ({placeholders}) AND s.responsable_domaine_contact_id IS NOT NULL
|
||||
UNION
|
||||
SELECT s.referent_technique_contact_id
|
||||
FROM patch_planning_import_rows r
|
||||
JOIN servers s ON s.id = r.server_id
|
||||
WHERE r.id IN ({placeholders}) AND s.referent_technique_contact_id IS NOT NULL
|
||||
UNION
|
||||
SELECT sar.contact_id
|
||||
FROM patch_planning_import_rows r
|
||||
JOIN server_additional_referents sar ON sar.server_id = r.server_id
|
||||
WHERE r.id IN ({placeholders})
|
||||
) x WHERE contact_id IS NOT NULL
|
||||
""")).fetchall()
|
||||
cids = [r.contact_id for r in contact_ids_rows if r.contact_id]
|
||||
if not cids:
|
||||
return []
|
||||
contacts = db.execute(text(f"""
|
||||
SELECT id, name, email FROM contacts
|
||||
WHERE id IN ({','.join(str(c) for c in cids)})
|
||||
AND email IS NOT NULL AND email <> ''
|
||||
AND is_active = true
|
||||
ORDER BY name
|
||||
""")).fetchall()
|
||||
return [{"name": c.name, "email": c.email} for c in contacts]
|
||||
|
||||
|
||||
@router.post("/patching/import/pct-prevenance/preview")
|
||||
async def pct_prevenance_preview(request: Request, db=Depends(get_db)):
|
||||
"""Construit l'aperçu mail PCT pour les rows sélectionnées sans envoyer.
|
||||
Body JSON : {row_ids: [int, ...]}"""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False, "msg": "Body JSON invalide"}, status_code=400)
|
||||
ids = [int(x) for x in (body.get("row_ids") or []) if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"}, status_code=400)
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
rows = db.execute(text(f"""
|
||||
SELECT asset_name, application_name, environnement, domaine,
|
||||
jour, heure_t, intervenant
|
||||
FROM patch_planning_import_rows
|
||||
WHERE id IN ({placeholders})
|
||||
ORDER BY jour NULLS LAST, heure_t NULLS LAST, asset_name
|
||||
""")).fetchall()
|
||||
if not rows:
|
||||
return JSONResponse({"ok": False, "msg": "Lignes introuvables"}, status_code=404)
|
||||
|
||||
rows_dicts = [
|
||||
{"asset_name": r.asset_name, "application_name": r.application_name,
|
||||
"environnement": r.environnement, "domaine": r.domaine,
|
||||
"jour": r.jour, "heure_t": r.heure_t, "intervenant": r.intervenant}
|
||||
for r in rows
|
||||
]
|
||||
intervenant_name = (rows[0].intervenant or user.get("sub") or "SecOps")
|
||||
subject, html = _build_pct_email(rows_dicts, intervenant_name=intervenant_name)
|
||||
|
||||
from ..services.mail_service import get_smtp_config
|
||||
smtp = get_smtp_config(db)
|
||||
from ..services.secrets_service import get_secret
|
||||
pct_recipient = (get_secret(db, "smtp_pct_recipient") or "PCT.reims@sanef.com").strip()
|
||||
cc_contacts = _fetch_pct_cc_emails(db, ids)
|
||||
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
"to": pct_recipient,
|
||||
"cc": cc_contacts, # [{name, email}]
|
||||
"smtp_configured": smtp is not None,
|
||||
"row_count": len(rows),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/patching/import/pct-prevenance/send")
|
||||
async def pct_prevenance_send(request: Request, db=Depends(get_db)):
|
||||
"""Envoie réellement le mail PCT pour les rows sélectionnées.
|
||||
Body JSON : {row_ids: [int, ...]}"""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not (can_edit(perms, "planning") or can_edit(perms, "campaigns")):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False, "msg": "Body JSON invalide"}, status_code=400)
|
||||
ids = [int(x) for x in (body.get("row_ids") or []) if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"}, status_code=400)
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
rows = db.execute(text(f"""
|
||||
SELECT id, asset_name, application_name, environnement, domaine,
|
||||
jour, heure_t, intervenant
|
||||
FROM patch_planning_import_rows
|
||||
WHERE id IN ({placeholders})
|
||||
ORDER BY jour NULLS LAST, heure_t NULLS LAST, asset_name
|
||||
""")).fetchall()
|
||||
if not rows:
|
||||
return JSONResponse({"ok": False, "msg": "Lignes introuvables"}, status_code=404)
|
||||
|
||||
rows_dicts = [
|
||||
{"asset_name": r.asset_name, "application_name": r.application_name,
|
||||
"environnement": r.environnement, "domaine": r.domaine,
|
||||
"jour": r.jour, "heure_t": r.heure_t, "intervenant": r.intervenant}
|
||||
for r in rows
|
||||
]
|
||||
intervenant_name = (rows[0].intervenant or user.get("sub") or "SecOps")
|
||||
subject, html = _build_pct_email(rows_dicts, intervenant_name=intervenant_name)
|
||||
|
||||
from ..services.secrets_service import get_secret
|
||||
pct_recipient = (get_secret(db, "smtp_pct_recipient") or "PCT.reims@sanef.com").strip()
|
||||
cc_list = [c["email"] for c in _fetch_pct_cc_emails(db, ids)]
|
||||
|
||||
from ..services.mail_service import send_html_mail
|
||||
res = send_html_mail(db, to=[pct_recipient], cc=cc_list, subject=subject, html=html)
|
||||
res["cc"] = cc_list
|
||||
|
||||
# Audit log
|
||||
if res.get("ok"):
|
||||
try:
|
||||
for rid in ids:
|
||||
db.execute(text("""
|
||||
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
|
||||
VALUES (:rid, 'pct_prevenance', :de, :uid)
|
||||
"""), {"rid": rid,
|
||||
"de": json.dumps({"to": pct_recipient, "subject": subject},
|
||||
ensure_ascii=False),
|
||||
"uid": user.get("uid")})
|
||||
# Marque les rows
|
||||
db.execute(text(f"""
|
||||
UPDATE patch_planning_import_rows
|
||||
SET pct_mail_sent_at = now()
|
||||
WHERE id IN ({placeholders})
|
||||
"""))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"[pct_prevenance] audit log failed: {e}")
|
||||
|
||||
return JSONResponse(res)
|
||||
"""Charge la row et applique les vérifs communes (éligible + Linux + hostname).
|
||||
Retourne (row_obj, error_response). error_response is None si OK."""
|
||||
if not _can_import(perms):
|
||||
|
||||
@ -78,6 +78,15 @@ SECTIONS = {
|
||||
("teams_sp_client_secret", "App Client Secret (futur webhook OAuth)", True),
|
||||
("teams_sp_tenant_id", "Tenant ID (futur webhook OAuth)", False),
|
||||
],
|
||||
"smtp": [
|
||||
("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),
|
||||
("smtp_pass", "Password SMTP (vide si relay anonyme)", True),
|
||||
("smtp_from", "From (ex: patchcenter@sanef.com)", False),
|
||||
("smtp_use_tls", "Utiliser TLS/STARTTLS (true/false)", False),
|
||||
("smtp_pct_recipient", "Destinataire prévenance PCT (ex: PCT.reims@sanef.com)", False),
|
||||
],
|
||||
"ldap": [
|
||||
("ldap_enabled", "Activer LDAP/AD (true/false)", False),
|
||||
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
|
||||
@ -123,6 +132,7 @@ SECTION_ACCESS = {
|
||||
"itop": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"ldap": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"smtp": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||
"security": {"visible": ["admin"], "editable": ["admin"]},
|
||||
}
|
||||
|
||||
|
||||
103
app/services/mail_service.py
Normal file
103
app/services/mail_service.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Service mail simple — envoi HTML via SMTP (Office 365 ou autre relay).
|
||||
|
||||
Configuration via Settings > SMTP :
|
||||
- smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from, smtp_use_tls
|
||||
|
||||
Si SMTP non configuré, send_html_mail retourne {ok:False, msg, html_preview}
|
||||
pour que l'UI affiche au moins le rendu sans envoi.
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Iterable
|
||||
|
||||
log = logging.getLogger("patchcenter.mail")
|
||||
|
||||
|
||||
def _get(db, key, default=""):
|
||||
try:
|
||||
from .secrets_service import get_secret
|
||||
return (get_secret(db, key) or default).strip()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def get_smtp_config(db):
|
||||
"""Lit la config SMTP depuis app_secrets. Retourne dict ou None si incomplet."""
|
||||
host = _get(db, "smtp_host")
|
||||
port_str = _get(db, "smtp_port", "587")
|
||||
sender = _get(db, "smtp_from")
|
||||
user = _get(db, "smtp_user")
|
||||
pwd = _get(db, "smtp_pass")
|
||||
use_tls = _get(db, "smtp_use_tls", "true").lower() in ("true", "1", "yes", "on")
|
||||
if not host or not sender:
|
||||
return None
|
||||
try:
|
||||
port = int(port_str or "587")
|
||||
except ValueError:
|
||||
port = 587
|
||||
return {
|
||||
"host": host, "port": port, "from": sender,
|
||||
"user": user, "pass": pwd, "use_tls": use_tls,
|
||||
}
|
||||
|
||||
|
||||
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}.
|
||||
"""
|
||||
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)"}
|
||||
|
||||
cfg = get_smtp_config(db)
|
||||
if not cfg:
|
||||
return {"ok": False, "msg": "Config SMTP incomplète (Settings > SMTP)",
|
||||
"error_kind": "config_missing",
|
||||
"html_preview": html, "subject_preview": subject, "sent_to": to}
|
||||
|
||||
if dry_run:
|
||||
return {"ok": True, "msg": "Dry-run — pas d'envoi",
|
||||
"html_preview": html, "subject_preview": subject, "sent_to": to}
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = cfg["from"]
|
||||
msg["To"] = ", ".join(to)
|
||||
if cc:
|
||||
cc = [a.strip() for a in cc if a and a.strip()]
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
|
||||
recipients = list(to) + (list(cc) if cc else [])
|
||||
try:
|
||||
if cfg["port"] == 465:
|
||||
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=30) as srv:
|
||||
if cfg["user"] and cfg["pass"]:
|
||||
srv.login(cfg["user"], cfg["pass"])
|
||||
srv.sendmail(cfg["from"], recipients, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=30) as srv:
|
||||
srv.ehlo()
|
||||
if cfg["use_tls"]:
|
||||
srv.starttls()
|
||||
srv.ehlo()
|
||||
if cfg["user"] and cfg["pass"]:
|
||||
srv.login(cfg["user"], cfg["pass"])
|
||||
srv.sendmail(cfg["from"], recipients, msg.as_string())
|
||||
log.info(f"Mail envoyé à {to} (subject={subject!r})")
|
||||
return {"ok": True, "msg": f"Envoyé à {', '.join(to)}",
|
||||
"sent_to": to, "subject_preview": subject}
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return {"ok": False, "msg": f"Auth SMTP refusée: {e}", "error_kind": "auth"}
|
||||
except smtplib.SMTPException as e:
|
||||
return {"ok": False, "msg": f"Erreur SMTP: {e}", "error_kind": "smtp"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": f"{type(e).__name__}: {e}", "error_kind": "other"}
|
||||
@ -155,6 +155,17 @@
|
||||
<button id="btn-prepatch" class="btn-action btn-pre" title="Lancer le pré-patching (rows éligibles uniquement)">
|
||||
▶ Pré-patching
|
||||
</button>
|
||||
{% if can_import %}
|
||||
<button id="btn-pct-mail" type="button" style="
|
||||
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
|
||||
border-radius: 6px; cursor: pointer; border: 1px solid #a78bfa;
|
||||
background: rgba(167,139,250,.15); color: #a78bfa;
|
||||
box-shadow: 0 0 8px #a78bfa; text-transform: uppercase;
|
||||
letter-spacing: 0.03em;"
|
||||
title="Envoie un mail à PCT.reims@sanef.com pour annoncer l'intervention sur les serveurs sélectionnés (preview avant envoi)">
|
||||
📧 Prévenance PCT
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="w-full text-xs" id="sheet-table">
|
||||
<thead class="text-cyber-accent border-b border-cyber-border">
|
||||
@ -468,7 +479,132 @@
|
||||
}
|
||||
window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
|
||||
});
|
||||
|
||||
// ─── Prévenance PCT — preview puis envoi ─────────────────────────
|
||||
const btnPctMail = document.getElementById('btn-pct-mail');
|
||||
if (btnPctMail) {
|
||||
btnPctMail.addEventListener('click', async () => {
|
||||
const ids = getSelectedRowIds();
|
||||
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||||
// 1) preview
|
||||
try {
|
||||
const r = await fetch('/patching/import/pct-prevenance/preview', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({row_ids: ids}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) { alert('Erreur preview : ' + (j.msg || '')); return; }
|
||||
showPctPreview(j, ids);
|
||||
} catch (e) { alert('Erreur réseau : ' + e); }
|
||||
});
|
||||
}
|
||||
|
||||
function showPctPreview(data, ids) {
|
||||
const modal = document.getElementById('pct-modal');
|
||||
document.getElementById('pct-subject').textContent = data.subject;
|
||||
document.getElementById('pct-to').textContent = data.to;
|
||||
const ccList = (data.cc || []);
|
||||
const ccHtml = ccList.length
|
||||
? ccList.map(c => '<span class="badge badge-blue">' + escapeHTML(c.name) + ' <' + escapeHTML(c.email) + '></span>').join(' ')
|
||||
: '<span class="text-gray-500">(aucun contact en CC — vérifie les responsables/référents en BDD)</span>';
|
||||
document.getElementById('pct-cc').innerHTML = ccHtml;
|
||||
document.getElementById('pct-iframe').srcdoc = data.html;
|
||||
document.getElementById('pct-smtp-warn').style.display = data.smtp_configured ? 'none' : '';
|
||||
document.getElementById('pct-rowcount').textContent = data.row_count;
|
||||
modal.classList.add('active');
|
||||
const btnSend = document.getElementById('pct-btn-send');
|
||||
btnSend.disabled = !data.smtp_configured;
|
||||
btnSend.onclick = async () => {
|
||||
if (!confirm('Envoyer ce mail à ' + data.to + ' (+ ' + ccList.length + ' en CC) ?')) return;
|
||||
btnSend.disabled = true; btnSend.textContent = '⏳ Envoi…';
|
||||
try {
|
||||
const r = await fetch('/patching/import/pct-prevenance/send', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({row_ids: ids}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.ok) {
|
||||
alert('✅ Mail envoyé.\n' + (j.msg || ''));
|
||||
modal.classList.remove('active');
|
||||
} else {
|
||||
alert('❌ Échec envoi : ' + (j.msg || 'inconnu'));
|
||||
}
|
||||
} catch (e) { alert('Erreur réseau : ' + e); }
|
||||
finally { btnSend.disabled = false; btnSend.textContent = '📤 Envoyer'; }
|
||||
};
|
||||
}
|
||||
function closePctModal() {
|
||||
document.getElementById('pct-modal').classList.remove('active');
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closePctModal();
|
||||
});
|
||||
document.getElementById('pct-btn-cancel')?.addEventListener('click', closePctModal);
|
||||
document.getElementById('pct-modal-bg')?.addEventListener('click', closePctModal);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{# Modal preview Prévenance PCT #}
|
||||
<style>
|
||||
#pct-modal { position:fixed; inset:0; z-index:9999; display:none; align-items:center; justify-content:center; }
|
||||
#pct-modal.active { display:flex; }
|
||||
#pct-modal-bg { position:absolute; inset:0; background:rgba(10,14,23,0.85); backdrop-filter:blur(2px); }
|
||||
.pct-dialog {
|
||||
position:relative; max-width:920px; width:95%; max-height:90vh;
|
||||
background:#0f172a; border:1px solid #334155; border-radius:8px;
|
||||
box-shadow: 0 0 40px rgba(167,139,250,0.4);
|
||||
display:flex; flex-direction:column;
|
||||
}
|
||||
.pct-header { padding:14px 20px; border-bottom:1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
|
||||
.pct-body { padding:14px 20px; overflow-y:auto; flex:1; }
|
||||
.pct-footer { padding:12px 20px; border-top:1px solid #334155; display:flex; gap:10px; justify-content:flex-end; }
|
||||
.pct-meta-row { display:flex; gap:10px; padding:6px 0; font-size:0.85rem; }
|
||||
.pct-meta-row .lbl { color:#9ca3af; min-width:90px; }
|
||||
.pct-meta-row .val { color:#e5e7eb; flex:1; word-break:break-all; }
|
||||
.pct-meta-row .val .badge { display:inline-block; margin:2px 4px 2px 0; padding:2px 8px; border-radius:4px; font-size:11px; }
|
||||
.pct-meta-row .val .badge-blue { background:rgba(59,130,246,0.15); color:#60a5fa; border:1px solid #3b82f6; }
|
||||
#pct-iframe { width:100%; height:60vh; border:1px solid #334155; border-radius:4px; background:#fff; }
|
||||
.pct-btn {
|
||||
padding:8px 16px; font-size:0.85rem; font-weight:700; border-radius:6px;
|
||||
cursor:pointer; border:1px solid; text-transform:uppercase; letter-spacing:0.03em;
|
||||
}
|
||||
.pct-btn-cancel { color:#9ca3af; border-color:#9ca3af; background:rgba(156,163,175,0.1); }
|
||||
.pct-btn-cancel:hover { background:rgba(156,163,175,0.25); }
|
||||
.pct-btn-send { color:#22c55e; border-color:#22c55e; background:rgba(34,197,94,0.15); }
|
||||
.pct-btn-send:hover { background:rgba(34,197,94,0.30); }
|
||||
.pct-btn-send:disabled { opacity:0.4; cursor:not-allowed; }
|
||||
</style>
|
||||
|
||||
<div id="pct-modal">
|
||||
<div id="pct-modal-bg"></div>
|
||||
<div class="pct-dialog">
|
||||
<div class="pct-header">
|
||||
<div>
|
||||
<div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#a78bfa;">Prévenance PCT</div>
|
||||
<h3 style="margin:4px 0 0;color:#e5e7eb;font-size:1.1rem;">Aperçu du mail avant envoi</h3>
|
||||
</div>
|
||||
<button onclick="document.getElementById('pct-modal').classList.remove('active')"
|
||||
style="background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:1.4rem;">✕</button>
|
||||
</div>
|
||||
<div class="pct-body">
|
||||
<div class="pct-meta-row"><span class="lbl">Destinataire :</span><span class="val" id="pct-to"></span></div>
|
||||
<div class="pct-meta-row"><span class="lbl">CC :</span><span class="val" id="pct-cc"></span></div>
|
||||
<div class="pct-meta-row"><span class="lbl">Objet :</span><span class="val" id="pct-subject" style="font-weight:600;"></span></div>
|
||||
<div class="pct-meta-row"><span class="lbl">Serveurs :</span><span class="val"><span id="pct-rowcount"></span> ligne(s) sélectionnée(s)</span></div>
|
||||
<div id="pct-smtp-warn" style="margin:8px 0;padding:8px 12px;background:rgba(239,68,68,0.15);border-left:3px solid #ef4444;border-radius:4px;color:#fca5a5;font-size:0.85rem;display:none;">
|
||||
⚠ <strong>SMTP non configuré</strong> dans Settings > SMTP. L'envoi est désactivé. Tu peux relire la preview ; configure SMTP plus tard pour activer l'envoi.
|
||||
</div>
|
||||
<h4 style="margin:14px 0 8px;color:#a78bfa;font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">Aperçu HTML du mail</h4>
|
||||
<iframe id="pct-iframe" sandbox=""></iframe>
|
||||
</div>
|
||||
<div class="pct-footer">
|
||||
<button id="pct-btn-cancel" class="pct-btn pct-btn-cancel">Annuler</button>
|
||||
<button id="pct-btn-send" class="pct-btn pct-btn-send">📤 Envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user