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:
@@ -0,0 +1,4 @@
|
||||
instance/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -700,6 +700,17 @@ def cleanup_old():
|
||||
return jsonify({"ok": True, "deleted": count})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# EmailConfig — 邮件配置持久化 (Web UI 写入, mailer 读取)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
class EmailConfig(db.Model):
|
||||
__tablename__ = 'email_config'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||
value = db.Column(db.Text, nullable=False, default='')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Email Routes (延迟导入 mailer, 避免在 mailer 缺失时整个 app 起不来)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -756,6 +767,80 @@ def email_preview():
|
||||
headers={"Content-Disposition": 'inline; filename="mail-preview.txt"'})
|
||||
|
||||
|
||||
@app.route("/email")
|
||||
def email_config():
|
||||
"""邮件配置页面。"""
|
||||
return render_template("email_config.html")
|
||||
|
||||
|
||||
@app.route("/api/email/config")
|
||||
def email_config_api():
|
||||
"""获取完整邮件配置 (含密码, 仅本地管理用)。"""
|
||||
ml = _get_mailer()
|
||||
if not ml:
|
||||
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
|
||||
cfg = ml.get_config_full()
|
||||
errs = ml.config_errors(cfg)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"configured": len(errs) == 0,
|
||||
"config_errors": errs,
|
||||
**cfg,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/email/config", methods=["POST"])
|
||||
def email_config_save():
|
||||
"""保存邮件配置到数据库。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
ml = _get_mailer()
|
||||
if not ml:
|
||||
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
|
||||
try:
|
||||
saved = ml.save_config(data)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"保存失败: {e}"}), 500
|
||||
cfg = ml.get_config_full()
|
||||
errs = ml.config_errors(cfg)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"saved": saved,
|
||||
"configured": len(errs) == 0,
|
||||
"config_errors": errs,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/email/reset", methods=["POST"])
|
||||
def email_config_reset():
|
||||
"""清除数据库配置, 回退到环境变量。"""
|
||||
ml = _get_mailer()
|
||||
if not ml:
|
||||
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
|
||||
try:
|
||||
ml.reset_config()
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"重置失败: {e}"}), 500
|
||||
cfg = ml.get_config_full()
|
||||
errs = ml.config_errors(cfg)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"message": "已清除数据库配置, 回退到环境变量",
|
||||
"configured": len(errs) == 0,
|
||||
"config_errors": errs,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/email/test-send", methods=["POST"])
|
||||
def email_test_send():
|
||||
"""发送测试邮件 (从 UI 手动触发)。"""
|
||||
ml = _get_mailer()
|
||||
if not ml:
|
||||
return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500
|
||||
result = ml.send_report()
|
||||
status = 200 if result["ok"] else 500
|
||||
return jsonify(result), status
|
||||
|
||||
|
||||
@app.route("/api/email/send", methods=["POST"])
|
||||
def email_send():
|
||||
"""
|
||||
@@ -798,7 +883,7 @@ def _scheduler_loop():
|
||||
print("[scheduler] 邮件调度线程已启动", flush=True)
|
||||
while True:
|
||||
try:
|
||||
cfg = mailer.get_config()
|
||||
cfg = mailer.get_config_full()
|
||||
if not cfg["enabled"]:
|
||||
# 关闭, 每小时看一眼
|
||||
time.sleep(3600)
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -690,6 +690,228 @@ body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ─── 邮件配置页面 ─── */
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 状态卡片 */
|
||||
.config-status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.config-status-card.status-ok { border-color: var(--green); background: rgba(63,185,80,0.06); }
|
||||
.config-status-card.status-error { border-color: var(--red); background: rgba(248,81,73,0.06); }
|
||||
.config-status-card.status-warning { border-color: var(--orange); background: rgba(210,153,34,0.06); }
|
||||
.status-icon { font-size: 24px; flex-shrink: 0; }
|
||||
.status-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.status-detail { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
|
||||
|
||||
/* 配置卡片 */
|
||||
.config-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.config-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.config-card-header h2 { font-size: 16px; font-weight: 700; }
|
||||
.config-source-badge {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
background: rgba(88,166,255,0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 表单通用 */
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group input[type="url"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="number"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.form-group textarea { resize: vertical; line-height: 1.5; }
|
||||
.label-hint { color: var(--text-muted); font-weight: 400; font-size: 11px; }
|
||||
|
||||
/* 小字段 (行内并排) */
|
||||
.form-group-small { margin-bottom: 14px; }
|
||||
|
||||
/* 表单分区 */
|
||||
.form-section {
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-section:first-of-type { border-top: none; padding-top: 0; }
|
||||
|
||||
/* 全宽字段 */
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
|
||||
/* 启用开关 */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toggle-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.toggle-desc { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-switch input { display: none; }
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border-light);
|
||||
border-radius: 12px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--accent); }
|
||||
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); }
|
||||
|
||||
/* 密码输入 */
|
||||
.password-input-wrap { position: relative; }
|
||||
.password-input-wrap input { padding-right: 36px; }
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 复选框组 */
|
||||
.checkbox-group { display: flex; gap: 16px; padding-top: 4px; }
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.form-actions .btn { white-space: nowrap; }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--text-secondary); color: var(--text-primary); }
|
||||
.form-actions .btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 信息卡片 */
|
||||
.info-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.info-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.info-card ul { margin: 0; padding-left: 18px; }
|
||||
.info-card li {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.topnav { padding: 0 12px; }
|
||||
@@ -698,4 +920,8 @@ body {
|
||||
.refresh-controls { margin-left: 0; }
|
||||
.boards-grid { grid-template-columns: 1fr; }
|
||||
.metrics-grid { grid-template-columns: 1fr; }
|
||||
.form-section { grid-template-columns: 1fr; }
|
||||
.toggle-row { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.form-actions { flex-direction: column; }
|
||||
.form-actions .btn { width: 100%; text-align: center; }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="nav-links">
|
||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">仪表板</a>
|
||||
<a href="{{ url_for('history') }}" class="{% if request.endpoint == 'history' %}active{% endif %}">历史记录</a>
|
||||
<a href="{{ url_for('email_config') }}" class="{% if request.endpoint == 'email_config' %}active{% endif %}">⚙️ 设置</a>
|
||||
<button class="nav-btn" onclick="openPasteModal()">📥 粘贴数据</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}邮件配置 — Palladium Z1 Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- ═══ 页面标题 ═══ -->
|
||||
<div class="page-header">
|
||||
<h1>📧 邮件配置</h1>
|
||||
<p class="page-subtitle">配置每日监控报告邮件发送。设置保存后立即生效,无需重启。</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 状态卡片 ═══ -->
|
||||
<div class="config-status-card" id="statusCard">
|
||||
<div class="status-icon" id="statusIcon">⏳</div>
|
||||
<div class="status-text">
|
||||
<div class="status-title" id="statusTitle">加载中...</div>
|
||||
<div class="status-detail" id="statusDetail">正在获取当前配置...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 配置表单 ═══ -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h2>⚙️ SMTP 配置</h2>
|
||||
<span class="config-source-badge" id="configSource">—</span>
|
||||
</div>
|
||||
|
||||
<form id="emailForm" onsubmit="return false;">
|
||||
|
||||
<!-- 启用开关 -->
|
||||
<div class="form-row toggle-row">
|
||||
<label class="toggle-label">
|
||||
<span class="toggle-title">启用每日邮件报告</span>
|
||||
<span class="toggle-desc">开启后每天定时发送监控报告</span>
|
||||
</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="cfg_enabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- SMTP 基础 -->
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="cfg_smtp_host">SMTP 服务器</label>
|
||||
<input type="text" id="cfg_smtp_host" placeholder="smtp.example.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group form-group-small">
|
||||
<label for="cfg_smtp_port">端口</label>
|
||||
<input type="number" id="cfg_smtp_port" placeholder="587" min="1" max="65535">
|
||||
</div>
|
||||
<div class="form-group form-group-small">
|
||||
<label>加密方式</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label"><input type="checkbox" id="cfg_smtp_use_tls"> STARTTLS</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="cfg_smtp_use_ssl"> SMTP SSL</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证 -->
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="cfg_smtp_user">用户名</label>
|
||||
<input type="text" id="cfg_smtp_user" placeholder="your@email.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cfg_smtp_password">密码 / 授权码</label>
|
||||
<div class="password-input-wrap">
|
||||
<input type="password" id="cfg_smtp_password" placeholder="输入密码">
|
||||
<button type="button" class="password-toggle" onclick="togglePassword()">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发件人 -->
|
||||
<div class="form-section">
|
||||
<div class="form-group">
|
||||
<label for="cfg_from_addr">发件邮箱</label>
|
||||
<input type="email" id="cfg_from_addr" placeholder="monitor@example.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cfg_from_name">发件人名称</label>
|
||||
<input type="text" id="cfg_from_name" placeholder="Palladium Z1 Monitor">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收件人 -->
|
||||
<div class="form-section">
|
||||
<div class="form-group full-width">
|
||||
<label for="cfg_to_addrs">收件邮箱 <span class="label-hint">(多个邮箱用逗号分隔)</span></label>
|
||||
<textarea id="cfg_to_addrs" rows="2" placeholder="admin@example.com, dev@example.com"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送时间 -->
|
||||
<div class="form-section">
|
||||
<div class="form-group form-group-small">
|
||||
<label for="cfg_send_hour">发送时间 (小时)</label>
|
||||
<input type="number" id="cfg_send_hour" placeholder="9" min="0" max="23">
|
||||
</div>
|
||||
<div class="form-group form-group-small">
|
||||
<label for="cfg_send_minute">发送时间 (分钟)</label>
|
||||
<input type="number" id="cfg_send_minute" placeholder="0" min="0" max="59">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cfg_monitor_base_url">监控服务器地址</label>
|
||||
<input type="url" id="cfg_monitor_base_url" placeholder="http://127.0.0.1:5100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveConfig()">💾 保存配置</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="testSend()" id="testSendBtn">📤 发送测试邮件</button>
|
||||
<a href="/api/email/preview" target="_blank" class="btn btn-secondary">👁 预览报告</a>
|
||||
<button type="button" class="btn btn-outline" onclick="resetConfig()">🔄 恢复环境变量默认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="info-card">
|
||||
<h3>ℹ️ 说明</h3>
|
||||
<ul>
|
||||
<li>配置保存到数据库后立即可用,邮件调度器会自动读取新配置。</li>
|
||||
<li>如果数据库和环境中都未配置,回退到环境变量 (SMTP_HOST, SMTP_USER 等)。</li>
|
||||
<li>「恢复环境变量默认」会清除数据库中的配置,回退到环境变量。</li>
|
||||
<li>手动发送测试邮件需先确保配置完整(无错误提示)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
const fields = [
|
||||
'enabled', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
|
||||
'smtp_use_tls', 'smtp_use_ssl', 'from_addr', 'from_name',
|
||||
'to_addrs', 'send_hour', 'send_minute', 'monitor_base_url'
|
||||
];
|
||||
|
||||
function setBusy(busy) {
|
||||
const btns = document.querySelectorAll('.form-actions .btn');
|
||||
btns.forEach(b => b.disabled = busy);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('statusIcon').textContent = '❌';
|
||||
document.getElementById('statusTitle').textContent = '配置错误';
|
||||
document.getElementById('statusDetail').textContent = msg;
|
||||
document.getElementById('statusCard').className = 'config-status-card status-error';
|
||||
}
|
||||
|
||||
function showConfigured(enabled) {
|
||||
document.getElementById('statusIcon').textContent = enabled ? '✅' : '⚠️';
|
||||
document.getElementById('statusTitle').textContent = enabled
|
||||
? '配置完整'
|
||||
: '配置不完整';
|
||||
document.getElementById('statusDetail').textContent = enabled
|
||||
? '所有必填项已填写,邮件功能已就绪。'
|
||||
: '缺少必要的配置项,邮件无法发送。';
|
||||
document.getElementById('statusCard').className = 'config-status-card '
|
||||
+ (enabled ? 'status-ok' : 'status-warning');
|
||||
}
|
||||
|
||||
function showErrors(errs) {
|
||||
document.getElementById('statusIcon').textContent = '⚠️';
|
||||
document.getElementById('statusTitle').textContent = '配置不完整';
|
||||
document.getElementById('statusDetail').textContent = errs.join(';');
|
||||
document.getElementById('statusCard').className = 'config-status-card status-warning';
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
fetch('/api/email/config')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.ok) {
|
||||
showError(data.error || '获取配置失败');
|
||||
return;
|
||||
}
|
||||
// 填充表单
|
||||
document.getElementById('cfg_enabled').checked = !!data.enabled;
|
||||
document.getElementById('cfg_smtp_host').value = data.smtp_host || '';
|
||||
document.getElementById('cfg_smtp_port').value = data.smtp_port || 587;
|
||||
document.getElementById('cfg_smtp_user').value = data.smtp_user || '';
|
||||
document.getElementById('cfg_smtp_password').value = data.smtp_password || '';
|
||||
document.getElementById('cfg_smtp_use_tls').checked = !!data.smtp_use_tls;
|
||||
document.getElementById('cfg_smtp_use_ssl').checked = !!data.smtp_use_ssl;
|
||||
document.getElementById('cfg_from_addr').value = data.from_addr || '';
|
||||
document.getElementById('cfg_from_name').value = data.from_name || '';
|
||||
document.getElementById('cfg_to_addrs').value = (data.to_addrs || []).join(', ');
|
||||
document.getElementById('cfg_send_hour').value = data.send_hour != null ? data.send_hour : 9;
|
||||
document.getElementById('cfg_send_minute').value = data.send_minute != null ? data.send_minute : 0;
|
||||
document.getElementById('cfg_monitor_base_url').value = data.monitor_base_url || '';
|
||||
|
||||
// 显示状态
|
||||
if (data.configured) {
|
||||
showConfigured(data.enabled);
|
||||
} else if (data.config_errors && data.config_errors.length > 0) {
|
||||
showErrors(data.config_errors);
|
||||
} else {
|
||||
showConfigured(!!data.enabled);
|
||||
}
|
||||
|
||||
// 来源标识
|
||||
document.getElementById('configSource').textContent = '数据库配置';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('网络请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
window.saveConfig = function() {
|
||||
setBusy(true);
|
||||
const data = {};
|
||||
data.enabled = document.getElementById('cfg_enabled').checked;
|
||||
data.smtp_host = document.getElementById('cfg_smtp_host').value.trim();
|
||||
data.smtp_port = parseInt(document.getElementById('cfg_smtp_port').value) || 587;
|
||||
data.smtp_user = document.getElementById('cfg_smtp_user').value.trim();
|
||||
data.smtp_password = document.getElementById('cfg_smtp_password').value;
|
||||
data.smtp_use_tls = document.getElementById('cfg_smtp_use_tls').checked;
|
||||
data.smtp_use_ssl = document.getElementById('cfg_smtp_use_ssl').checked;
|
||||
data.from_addr = document.getElementById('cfg_from_addr').value.trim();
|
||||
data.from_name = document.getElementById('cfg_from_name').value.trim();
|
||||
const toRaw = document.getElementById('cfg_to_addrs').value.trim();
|
||||
data.to_addrs = toRaw ? toRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
|
||||
// 校验时间范围
|
||||
let h = parseInt(document.getElementById('cfg_send_hour').value);
|
||||
let m = parseInt(document.getElementById('cfg_send_minute').value);
|
||||
if (isNaN(h) || h < 0 || h > 23) {
|
||||
setBusy(false);
|
||||
showError('发送时间(小时)必须在 0–23 之间');
|
||||
document.getElementById('cfg_send_hour').focus();
|
||||
return;
|
||||
}
|
||||
if (isNaN(m) || m < 0 || m > 59) {
|
||||
setBusy(false);
|
||||
showError('发送时间(分钟)必须在 0–59 之间');
|
||||
document.getElementById('cfg_send_minute').focus();
|
||||
return;
|
||||
}
|
||||
data.send_hour = h;
|
||||
data.send_minute = m;
|
||||
data.monitor_base_url = document.getElementById('cfg_monitor_base_url').value.trim();
|
||||
|
||||
fetch('/api/email/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
if (result.configured) {
|
||||
showConfigured(result.enabled);
|
||||
} else if (result.config_errors && result.config_errors.length > 0) {
|
||||
showErrors(result.config_errors);
|
||||
}
|
||||
} else {
|
||||
showError(result.error || '保存失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setBusy(false);
|
||||
showError('保存失败: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
window.testSend = function() {
|
||||
if (!confirm('确定要发送一封测试邮件到当前配置的收件人吗?')) return;
|
||||
setBusy(true);
|
||||
const btn = document.getElementById('testSendBtn');
|
||||
btn.textContent = '⏳ 发送中...';
|
||||
fetch('/api/email/test-send', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
setBusy(false);
|
||||
btn.textContent = '📤 发送测试邮件';
|
||||
if (result.ok) {
|
||||
document.getElementById('statusIcon').textContent = '✅';
|
||||
document.getElementById('statusTitle').textContent = '发送成功';
|
||||
document.getElementById('statusDetail').textContent =
|
||||
'已发送到: ' + (result.recipients || []).join(', ');
|
||||
document.getElementById('statusCard').className = 'config-status-card status-ok';
|
||||
} else {
|
||||
showError(result.error || '发送失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setBusy(false);
|
||||
btn.textContent = '📤 发送测试邮件';
|
||||
showError('发送失败: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
window.resetConfig = function() {
|
||||
if (!confirm('确定要清除数据库配置并回退到环境变量吗?')) return;
|
||||
setBusy(true);
|
||||
fetch('/api/email/reset', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
document.getElementById('configSource').textContent = '环境变量';
|
||||
loadConfig(); // 重新加载
|
||||
} else {
|
||||
showError(result.error || '重置失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setBusy(false);
|
||||
showError('重置失败: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
window.togglePassword = function() {
|
||||
const inp = document.getElementById('cfg_smtp_password');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
};
|
||||
|
||||
// 初始化
|
||||
loadConfig();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user