feat: 邮件配置页面 + 数据库持久化

- 新增 /email 配置页面 + /api/email/config API (GET/POST)
- 新增 /api/email/test-send 测试邮件 + /api/email/reset 重置
- EmailConfig SQLite 表持久化配置,回退到环境变量
- mailer: get_config 优先读 DB,send_report 读取 DB 密码
- 修复 _send_smtp 登录使用环境变量密码的 bug
- 前端新增配置表单 (启用开关/SMTP/TLS/认证/发件人/收件人/时间)
- CSS 暗色主题样式 + 响应式适配
- 导航栏新增「设置」链接
This commit is contained in:
Your Name
2026-07-08 14:36:41 +08:00
parent 3ab73805a3
commit 89dc6b4449
7 changed files with 788 additions and 10 deletions
+141 -9
View File
@@ -34,19 +34,46 @@ def _bool(name: str, default: bool = False) -> bool:
return default
def _int(name: str, default: int) -> int:
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip())
except (TypeError, ValueError):
return default
def get_config() -> dict:
"""读取并返回当前生效的邮件配置 (用于 /api/email/status 显示)。"""
# ═══════════════════════════════════════════════════════════════════════════
# 数据库配置持久化
# ═══════════════════════════════════════════════════════════════════════════
def _get_email_config_model():
"""惰性获取 EmailConfig 模型 (避免循环导入)。"""
try:
from app import EmailConfig
return EmailConfig
except (ImportError, AttributeError):
return None
def _read_db_config() -> Optional[Dict[str, str]]:
"""从 EmailConfig 表读取配置, 返回 {key: value} dict, 无记录返回 None。"""
Model = _get_email_config_model()
if not Model:
return None
try:
with app.app_context():
rows = Model.query.all()
if not rows:
return None
return {r.key: r.value for r in rows}
except Exception:
return None
def _build_config_from_env() -> dict:
"""从环境变量读取配置 (原始逻辑)。"""
return {
"enabled": _bool("MAIL_ENABLED", False),
"smtp_host": os.environ.get("SMTP_HOST", "").strip(),
"smtp_port": _int("SMTP_PORT", 587),
"smtp_port": _int_env("SMTP_PORT", 587),
"smtp_user": os.environ.get("SMTP_USER", "").strip(),
"smtp_password_set": bool(os.environ.get("SMTP_PASSWORD", "").strip()),
"smtp_use_tls": _bool("SMTP_USE_TLS", True),
@@ -54,12 +81,113 @@ def get_config() -> dict:
"from_addr": os.environ.get("MAIL_FROM", "").strip(),
"from_name": os.environ.get("MAIL_FROM_NAME", "Palladium Z1 Monitor").strip(),
"to_addrs": [a.strip() for a in os.environ.get("MAIL_TO", "").split(",") if a.strip()],
"send_hour": _int("MAIL_SEND_HOUR", 9), # 每天 09:00
"send_minute": _int("MAIL_SEND_MINUTE", 0),
"send_hour": _int_env("MAIL_SEND_HOUR", 9),
"send_minute": _int_env("MAIL_SEND_MINUTE", 0),
"monitor_base_url": os.environ.get("MONITOR_BASE_URL", "http://127.0.0.1:5100").strip(),
}
def _build_config_from_dict(raw: Dict[str, str]) -> dict:
"""从数据库 {key: value} dict 构建配置。"""
def _s(key: str, default: str = "") -> str:
return raw.get(key, default).strip()
def _b(key: str, default: bool) -> bool:
v = raw.get(key, "").strip().lower()
return v in ("1", "true", "yes", "y", "on")
def _i(key: str, default: int) -> int:
try:
return int(raw.get(key, str(default)))
except (TypeError, ValueError):
return default
return {
"enabled": _b("enabled", False),
"smtp_host": _s("smtp_host"),
"smtp_port": _i("smtp_port", 587),
"smtp_user": _s("smtp_user"),
"smtp_password_set": bool(_s("smtp_password")),
"smtp_use_tls": _b("smtp_use_tls", True),
"smtp_use_ssl": _b("smtp_use_ssl", False),
"from_addr": _s("from_addr"),
"from_name": _s("from_name") or "Palladium Z1 Monitor",
"to_addrs": [a.strip() for a in _s("to_addrs").split(",") if a.strip()],
"send_hour": _i("send_hour", 9),
"send_minute": _i("send_minute", 0),
"monitor_base_url": _s("monitor_base_url") or "http://127.0.0.1:5100",
}
def save_config(data: dict) -> int:
"""保存配置到数据库。返回保存的键数。"""
Model = _get_email_config_model()
if not Model:
raise RuntimeError("EmailConfig 模型不可用")
to_addrs = data.get("to_addrs")
if isinstance(to_addrs, list):
to_addrs_str = ",".join(str(x) for x in to_addrs if x)
else:
to_addrs_str = str(to_addrs or "")
save_fields = {
"enabled": str(data.get("enabled", False)).lower(),
"smtp_host": str(data.get("smtp_host", "")).strip(),
"smtp_port": str(data.get("smtp_port", 587)),
"smtp_user": str(data.get("smtp_user", "")).strip(),
"smtp_password": str(data.get("smtp_password", "")),
"smtp_use_tls": str(data.get("smtp_use_tls", True)).lower(),
"smtp_use_ssl": str(data.get("smtp_use_ssl", False)).lower(),
"from_addr": str(data.get("from_addr", "")).strip(),
"from_name": str(data.get("from_name", "Palladium Z1 Monitor")).strip(),
"to_addrs": to_addrs_str.strip(),
"send_hour": str(data.get("send_hour", 9)),
"send_minute": str(data.get("send_minute", 0)),
"monitor_base_url": str(data.get("monitor_base_url", "http://127.0.0.1:5100")).strip(),
}
count = 0
with app.app_context():
for key, value in save_fields.items():
row = Model.query.filter_by(key=key).first()
if row:
row.value = value
else:
db.session.add(Model(key=key, value=value))
count += 1
db.session.commit()
return count
def reset_config() -> bool:
"""清除数据库配置, 回退到环境变量。"""
Model = _get_email_config_model()
if not Model:
return False
with app.app_context():
Model.query.delete()
db.session.commit()
return True
def get_config() -> dict:
"""读取邮件配置。优先使用数据库存储的配置, 回退到环境变量。"""
db_raw = _read_db_config()
if db_raw is not None:
return _build_config_from_dict(db_raw)
return _build_config_from_env()
def get_config_full() -> dict:
"""读取完整配置 (含密码原始值)。优先 DB, 回退到环境变量。"""
cfg = get_config()
db_raw = _read_db_config()
if db_raw is not None:
cfg["smtp_password"] = db_raw.get("smtp_password", "").strip()
else:
cfg["smtp_password"] = os.environ.get("SMTP_PASSWORD", "").strip()
return cfg
def config_errors(cfg: dict) -> List[str]:
"""检查配置是否完整可发送。返回缺失/错误项的列表 (空=OK)。"""
errs = []
@@ -440,12 +568,16 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str
msg.attach(MIMEText(text, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
password = cfg.get("smtp_password", "")
if not password:
password = os.environ.get("SMTP_PASSWORD", "")
try:
if cfg["smtp_use_ssl"]:
# SMTP_SSL (port 465)
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], context=ctx, timeout=30) as s:
s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", ""))
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
else:
# STARTTLS (port 587) 或 明文 (port 25)
@@ -454,7 +586,7 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str
if cfg["smtp_use_tls"]:
s.starttls()
s.ehlo()
s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", ""))
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
return (True, "")
except smtplib.SMTPAuthenticationError as e:
@@ -474,7 +606,7 @@ def send_report(cfg: Optional[Dict] = None) -> dict:
收集数据 → 渲染 → 发送。
返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str}
"""
cfg = cfg or get_config()
cfg = cfg or get_config_full()
if not cfg["enabled"]:
return {"ok": False, "error": "邮件功能未启用 (MAIL_ENABLED 未设 true)", "subject": "", "recipients": []}
errs = config_errors(cfg)