Files
Alert-Hub-System/app/mailer.py
T
2026-09-13 19:59:54 +01:00

162 lines
6.7 KiB
Python

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