"""SMTP mailer that pulls live config from the AppSetting store. Every send is recorded in the MailLog (visible in the backoffice "Mail Log"), and — when IMAP is configured — a copy of the outgoing message is appended to the external mailbox's Sent folder so it shows up in normal mail clients. """ import smtplib import imaplib import ssl import time import logging from email.message import EmailMessage from email.utils import formataddr, formatdate from flask_login import current_user from .models import AppSetting, MailLog logger = logging.getLogger(__name__) def get_mail_config() -> dict: """Read SMTP + IMAP settings from the database.""" return { # ---- SMTP (outgoing) ---- "host": AppSetting.get("smtp_host", "") or "", "port": int(AppSetting.get("smtp_port", "465") or "465"), "username": AppSetting.get("smtp_username", "") or "", "password": AppSetting.get("smtp_password", "") or "", "encryption": (AppSetting.get("smtp_encryption", "ssl") or "ssl").lower(), "from_address": AppSetting.get("smtp_from_address", "") or "", "from_name": AppSetting.get("smtp_from_name", "AlertHub") or "AlertHub", # ---- IMAP (for saving a copy in the Sent folder) ---- "imap_enabled": (AppSetting.get("imap_enabled", "true") or "true").lower() in ("1", "true", "yes", "on"), "imap_host": AppSetting.get("imap_host", "") or "", "imap_port": int(AppSetting.get("imap_port", "993") or "993"), "imap_username": AppSetting.get("imap_username", "") or "", "imap_password": AppSetting.get("imap_password", "") or "", "imap_sent_folder": AppSetting.get("imap_sent_folder", "Sent") or "Sent", } def _build_message(cfg, to_addresses, subject, body_text, body_html=None, attachments=None) -> EmailMessage: msg = EmailMessage() msg["Subject"] = subject msg["From"] = formataddr((cfg["from_name"], cfg["from_address"])) msg["To"] = ", ".join(to_addresses) msg["Date"] = formatdate(localtime=True) msg.set_content(body_text) if body_html: msg.add_alternative(body_html, subtype="html") for att in (attachments or []): # att = (filename, mime_maintype, mime_subtype, bytes) fname, maintype, subtype, data = att msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=fname) return msg def _append_to_sent(cfg, msg) -> tuple[bool, str]: """Append a copy of the message to the mailbox's Sent folder over IMAP.""" if not cfg.get("imap_enabled"): return False, "disabled" imap_host = cfg["imap_host"] or "" imap_user = cfg["imap_username"] or cfg["username"] imap_pass = cfg["imap_password"] or cfg["password"] if not imap_host or not imap_user or not imap_pass: return False, "IMAP not fully configured" folder = cfg.get("imap_sent_folder") or "Sent" try: context = ssl.create_default_context() with imaplib.IMAP4_SSL(imap_host, cfg.get("imap_port", 993), ssl_context=context, timeout=20) as imap: imap.login(imap_user, imap_pass) raw = msg.as_bytes() # \Seen so it doesn't show as unread in the Sent folder typ, _ = imap.append(folder, "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw) if typ != "OK": return False, f"IMAP append returned {typ}" return True, "saved" except (imaplib.IMAP4.error, ssl.SSLError, OSError) as e: logger.exception("IMAP append to Sent failed") return False, f"IMAP error: {e}" def _smtp_send(cfg, msg) -> tuple[bool, str]: encryption = cfg["encryption"] try: if encryption == "ssl": context = ssl.create_default_context() with smtplib.SMTP_SSL(cfg["host"], cfg["port"], context=context, timeout=20) as server: if cfg["username"]: server.login(cfg["username"], cfg["password"]) server.send_message(msg) else: with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server: server.ehlo() if encryption == "starttls": context = ssl.create_default_context() server.starttls(context=context) server.ehlo() if cfg["username"]: server.login(cfg["username"], cfg["password"]) server.send_message(msg) return True, "ok" except smtplib.SMTPAuthenticationError as e: logger.exception("SMTP auth failed") err = e.smtp_error.decode("utf-8", errors="ignore") if isinstance(e.smtp_error, bytes) else e.smtp_error return False, f"Authentication failed: {e.smtp_code} {err}" except (smtplib.SMTPException, ssl.SSLError, OSError) as e: logger.exception("SMTP error") return False, f"SMTP error: {e}" def send_mail(to_addresses, subject: str, body_text: str, body_html: str | None = None, attachments=None, kind: str = "general", triggered_by: str | None = None) -> tuple[bool, str]: """Send an email. Returns (success, message). Never raises. Side effects: - Appends a copy to the external mailbox Sent folder (IMAP) on success. - Records the send (and Sent-copy result) in the MailLog. """ cfg = get_mail_config() # Resolve who triggered this (username) for the log. if triggered_by is None: try: triggered_by = current_user.username if current_user.is_authenticated else "system" except Exception: triggered_by = "system" if not cfg["host"] or not cfg["from_address"]: info = "SMTP not configured (host or from-address missing)." MailLog.record(kind, to_addresses, subject, "failed", info, "n/a", triggered_by) return False, info if isinstance(to_addresses, str): to_addresses = [to_addresses] to_addresses = [a for a in to_addresses if a] if not to_addresses: info = "No recipients." MailLog.record(kind, "", subject, "failed", info, "n/a", triggered_by) return False, info msg = _build_message(cfg, to_addresses, subject, body_text, body_html, attachments) ok, info = _smtp_send(cfg, msg) sent_copy = "n/a" if ok: copied, copy_info = _append_to_sent(cfg, msg) if copy_info == "disabled": sent_copy = "disabled" elif copied: sent_copy = "saved" else: sent_copy = "failed" info = f"{info}; Sent-copy: {copy_info}" MailLog.record(kind, to_addresses, subject, "sent" if ok else "failed", info, sent_copy, triggered_by) if ok: return True, f"Sent to {len(to_addresses)} recipient(s)." return False, info