feat(email): extract notification templates

This commit is contained in:
2026-05-05 12:18:18 +08:00
parent 8d3773a2eb
commit a780c1bf52
13 changed files with 878 additions and 633 deletions
+13
View File
@@ -0,0 +1,13 @@
from backend.email_templates.renderer import (
EmailTemplate,
EmailTemplateRenderError,
SafeHtml,
render_email_template,
)
__all__ = [
"EmailTemplate",
"EmailTemplateRenderError",
"SafeHtml",
"render_email_template",
]
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from html import escape
from pathlib import Path
from string import Template
from typing import Any
class EmailTemplateRenderError(RuntimeError):
pass
class SafeHtml(str):
pass
class EmailTemplate(StrEnum):
NEW_USER_REGISTRATION = "new-user-registration"
USER_APPROVED = "user-approved"
USER_REJECTED = "user-rejected"
TOKEN_EXPIRING = "token-expiring"
TOKEN_EXPIRED = "token-expired"
CHECK_IN_RESULT = "check-in-result"
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
TEMPLATE_META = {
EmailTemplate.NEW_USER_REGISTRATION: {
"title": "新用户注册通知",
"subtitle": "请及时审批新注册账户",
"header_background": "#4f46e5",
},
EmailTemplate.USER_APPROVED: {
"title": "账户审批通过",
"subtitle": "您现在可以使用接龙自动打卡系统",
"header_background": "#15803d",
},
EmailTemplate.USER_REJECTED: {
"title": "账户审批结果通知",
"subtitle": "您的注册申请未通过审批",
"header_background": "#b91c1c",
},
EmailTemplate.TOKEN_EXPIRING: {
"title": "登录凭证即将过期",
"subtitle": "请尽快刷新 QQ 登录凭证",
"header_background": "#c2410c",
},
EmailTemplate.TOKEN_EXPIRED: {
"title": "登录凭证已过期",
"subtitle": "自动打卡任务已无法继续执行",
"header_background": "#b91c1c",
},
EmailTemplate.CHECK_IN_RESULT: {
"title": "打卡结果通知",
"subtitle": "自动打卡任务执行结果",
"header_background": "#2563eb",
},
}
def _load_template(template_name: str) -> Template:
template_path = TEMPLATE_DIR / f"{template_name}.html"
if not template_path.exists():
raise EmailTemplateRenderError(f"email template file missing: {template_name}")
return Template(template_path.read_text(encoding="utf-8"))
def _escape_context(context: dict[str, Any]) -> dict[str, str]:
escaped: dict[str, str] = {}
for key, value in context.items():
if isinstance(value, SafeHtml):
escaped[key] = str(value)
elif value is None:
escaped[key] = ""
else:
escaped[key] = escape(str(value), quote=True)
return escaped
def _substitute(template_obj: Template, context: dict[str, Any]) -> str:
try:
return template_obj.substitute(context)
except KeyError as exc:
missing_key = exc.args[0]
raise EmailTemplateRenderError(
f"missing required email template context: {missing_key}"
) from exc
def render_email_template(template: EmailTemplate, context: dict[str, Any]) -> str:
escaped_context = _escape_context(context)
body = _substitute(_load_template(template.value), escaped_context)
meta = TEMPLATE_META[template]
footer_year = context.get("year") or datetime.now().year
return _substitute(
_load_template("base"),
{
"title": meta["title"],
"subtitle": meta["subtitle"],
"header_background": meta["header_background"],
"body": body,
"year": str(footer_year),
},
)
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
margin: 0;
padding: 0;
background: #f4f6fb;
color: #22303f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
line-height: 1.6;
}
.email-shell {
max-width: 640px;
margin: 0 auto;
padding: 24px 16px 32px;
}
.email-card {
background: #ffffff;
border: 1px solid #dde4ee;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 24px rgba(15, 23, 42, 0.06);
}
.email-header {
padding: 24px 28px;
color: #ffffff;
}
.email-title {
margin: 0;
font-size: 22px;
line-height: 1.3;
letter-spacing: 0;
}
.email-subtitle {
margin: 8px 0 0;
font-size: 14px;
opacity: 0.95;
}
.email-body {
padding: 28px;
background: #ffffff;
}
.email-body p {
margin: 0 0 14px;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin: 16px 0 20px;
border: 1px solid #e3e9f2;
border-radius: 10px;
overflow: hidden;
}
.info-table td {
padding: 12px 14px;
border-bottom: 1px solid #e3e9f2;
vertical-align: top;
}
.info-table tr:last-child td {
border-bottom: 0;
}
.info-label {
width: 140px;
font-weight: 700;
color: #475569;
background: #f8fafc;
}
.status-box {
margin: 18px 0;
padding: 16px 18px;
border-left: 4px solid var(--accent);
border-radius: 10px;
background: var(--surface);
}
.status-title {
margin: 0 0 8px;
font-weight: 700;
}
.status-list {
margin: 10px 0 0;
padding-left: 20px;
}
.status-list li {
margin: 6px 0;
}
.action-wrap {
text-align: center;
margin: 24px 0 8px;
}
.action-link {
display: inline-block;
padding: 12px 22px;
border-radius: 10px;
color: #ffffff !important;
text-decoration: none;
background: var(--accent);
font-weight: 700;
}
.muted {
color: #64748b;
}
.email-footer {
padding: 18px 28px 24px;
text-align: center;
color: #7c8797;
font-size: 12px;
background: #fbfcfe;
border-top: 1px solid #e6edf5;
}
.email-footer p {
margin: 4px 0;
}
@media (max-width: 640px) {
.email-shell {
padding: 0;
}
.email-card {
border-radius: 0;
border-left: 0;
border-right: 0;
}
.email-header,
.email-body,
.email-footer {
padding-left: 18px;
padding-right: 18px;
}
.info-label {
width: 110px;
}
}
</style>
</head>
<body>
<div class="email-shell">
<div class="email-card">
<div class="email-header" style="background: ${header_background};">
<h1 class="email-title">${title}</h1>
<p class="email-subtitle">${subtitle}</p>
</div>
<div class="email-body">
${body}
</div>
<div class="email-footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © ${year}</p>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,20 @@
<p>您好,${user_alias}</p>
<p>您的接龙自动打卡任务已执行。</p>
<table class="info-table">
<tr>
<td class="info-label">执行时间</td>
<td>${executed_time}</td>
</tr>
<tr>
<td class="info-label">任务 ID</td>
<td>${thread_id}</td>
</tr>
<tr>
<td class="info-label">打卡状态</td>
<td><strong style="color: ${status_color};">${status_text}</strong></td>
</tr>
${message_row}
</table>
${guidance_section}
@@ -0,0 +1,29 @@
<p>尊敬的管理员,</p>
<p>有新用户注册了接龙自动打卡系统,请及时审批。</p>
<table class="info-table">
<tr>
<td class="info-label">用户名</td>
<td>${user_alias}</td>
</tr>
<tr>
<td class="info-label">用户 ID</td>
<td>${user_id}</td>
</tr>
<tr>
<td class="info-label">注册时间</td>
<td>${created_time}</td>
</tr>
</table>
<div class="status-box" style="--accent: #c2410c; --surface: #fff7ed;">
<p class="status-title">重要提示</p>
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
<p>请登录管理后台进行审批操作。</p>
</div>
<div class="action-wrap">
<a href="${admin_url}" class="action-link" style="--accent: #4f46e5;">前往用户管理</a>
</div>
<p class="muted">管理地址:<a href="${admin_url}">${admin_url}</a></p>
@@ -0,0 +1,22 @@
<p>您好,${user_alias}</p>
<p>您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。</p>
<div class="status-box" style="--accent: #b91c1c; --surface: #fef2f2;">
<p class="status-title">重要提示</p>
<ul class="status-list">
<li>登录凭证已过期,所有自动打卡任务已暂停</li>
<li>请尽快登录系统刷新凭证以恢复服务</li>
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
</ul>
</div>
<p><strong>如何刷新 Token</strong></p>
<ol class="status-list">
<li>登录系统(扫码或密码登录)</li>
<li>在个人设置旁的按钮中进行刷新 Token</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<div class="action-wrap">
<a href="${login_url}" class="action-link" style="--accent: #b91c1c;">立即登录刷新</a>
</div>
@@ -0,0 +1,13 @@
<p>您好,${user_alias}</p>
<p>您的 QQ 登录凭证即将在 <strong>${minutes_left} 分钟</strong>后过期。</p>
<div class="status-box" style="--accent: #c2410c; --surface: #fff7ed;">
<p class="status-title">重要提示</p>
<ul class="status-list">
<li>登录凭证过期后,系统将无法自动执行您的打卡任务</li>
<li>当前凭证尚未过期,暂时无需在网页中刷新</li>
<li>如果后续打卡失败,系统会在失败通知中提供前往网页的入口</li>
</ul>
</div>
<p class="muted">这封邮件仅用于提前提醒凭证状态,实际处理入口会出现在打卡失败通知中。</p>
@@ -0,0 +1,35 @@
<p>您好,${user_alias}</p>
<p>恭喜您的账户已通过管理员审批,现在可以使用所有功能了。</p>
<div class="status-box" style="--accent: #15803d; --surface: #f0fdf4;">
<p class="status-title">审批结果:已通过</p>
<p>审批时间:${approved_time}</p>
</div>
<table class="info-table">
<tr>
<td class="info-label">用户名</td>
<td>${user_alias}</td>
</tr>
<tr>
<td class="info-label">账户角色</td>
<td>${user_role}</td>
</tr>
<tr>
<td class="info-label">注册时间</td>
<td>${created_time}</td>
</tr>
</table>
<p><strong>接下来您可以:</strong></p>
<ul class="status-list">
<li>登录系统创建自动打卡任务</li>
<li>配置打卡时间和内容</li>
<li>查看打卡记录和统计</li>
</ul>
<div class="action-wrap">
<a href="${login_url}" class="action-link" style="--accent: #15803d;">立即登录</a>
</div>
<p class="muted">如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。</p>
@@ -0,0 +1,11 @@
<p>您好,${user_alias}</p>
<p>很遗憾,您的账户注册申请未能通过审批。</p>
<div class="status-box" style="--accent: #b91c1c; --surface: #fef2f2;">
<p class="status-title">审批结果:未通过</p>
<p>处理时间:${processed_time}</p>
</div>
${reason_section}
<p>如有疑问,请联系系统管理员。</p>