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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user