Files
tick-stock-panel/backend/app/services/email_adapter.py
T

105 lines
3.4 KiB
Python

"""SMTP email notification adapter.
Transport failures are isolated from alert persistence and SSE delivery. SMTP credentials
are supplied by the caller from ``secrets_store`` and never logged here.
"""
from __future__ import annotations
import logging
import smtplib
import time
from contextlib import suppress
from email.message import EmailMessage
from email.utils import parseaddr
logger = logging.getLogger(__name__)
SECURITY_MODES = {"ssl", "starttls", "none"}
_MAX_ATTEMPTS = 2
def is_valid_email(address: str) -> bool:
"""Small dependency-free mailbox validation suitable for configuration checks."""
parsed = parseaddr((address or "").strip())[1]
if parsed != (address or "").strip() or parsed.count("@") != 1:
return False
local, domain = parsed.rsplit("@", 1)
return bool(local and domain and "." in domain and " " not in parsed)
def is_configured(config: dict) -> bool:
"""Return whether the non-secret fields are sufficient to attempt delivery."""
sender = str(config.get("from_address") or config.get("username") or "").strip()
recipients = config.get("to_addresses") or []
return bool(config.get("host") and sender and recipients)
def send_email(
config: dict,
password: str,
subject: str,
body: str,
*,
max_attempts: int = _MAX_ATTEMPTS,
) -> bool:
"""Send one UTF-8 plain-text email through SSL, STARTTLS, or plain SMTP."""
if not is_configured(config):
return False
host = str(config.get("host") or "").strip()
try:
port = int(config.get("port", 465))
except (TypeError, ValueError):
return False
security = str(config.get("security") or "ssl")
username = str(config.get("username") or "").strip()
sender = str(config.get("from_address") or username).strip()
recipients = [str(item).strip() for item in config.get("to_addresses", [])]
if (
not 1 <= port <= 65535
or security not in SECURITY_MODES
or not is_valid_email(sender)
or not recipients
or any(not is_valid_email(item) for item in recipients)
):
return False
message = EmailMessage()
message["Subject"] = str(subject or "TickFlow 通知")
message["From"] = sender
message["To"] = ", ".join(recipients)
message.set_content(str(body or ""))
last_err = ""
for attempt in range(1, max_attempts + 1):
smtp = None
try:
if security == "ssl":
smtp = smtplib.SMTP_SSL(host, port, timeout=10)
else:
smtp = smtplib.SMTP(host, port, timeout=10)
if security == "starttls":
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
if username:
smtp.login(username, password)
smtp.send_message(message)
# Delivery already succeeded; a failed QUIT must not retry and duplicate the email.
try:
smtp.quit()
except Exception:
with suppress(Exception):
smtp.close()
return True
except Exception as exc: # SMTP/network errors must not escape
last_err = str(exc)
if smtp is not None:
with suppress(Exception):
smtp.close()
if attempt < max_attempts:
time.sleep(1)
logger.warning("邮件推送最终失败(已尝试 %d 次): %s", max_attempts, last_err)
return False