162 lines
6.7 KiB
Python
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
|