"""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"}