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>
+141 -633
View File
@@ -11,16 +11,99 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import List from html import escape
from typing import Any, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.models import User
from backend.workers.email_notifier import EmailNotifier
from backend.config import settings from backend.config import settings
from backend.models import User
from backend.services.email_templates import EmailTemplate, SafeHtml, render_email_template
from backend.utils.time_helpers import minutes_until_expiry, parse_jwt_exp
from backend.workers.email_notifier import EmailNotifier
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _format_datetime(value: Any) -> str:
if value is None:
return "未知"
try:
return value.strftime("%Y-%m-%d %H:%M:%S")
except AttributeError:
return str(value)
def _now_text() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _frontend_path(path: str) -> str:
return f"{settings.FRONTEND_URL}{path}"
def _email_text(value: object) -> str:
return escape(str(value), quote=True)
def _optional_reason_section(reason: str) -> SafeHtml:
if not reason:
return SafeHtml("")
return SafeHtml(
'<div class="status-box" style="--accent: #64748b; --surface: #f8fafc;">'
'<p class="status-title">拒绝原因</p>'
f"<p>{_email_text(reason)}</p>"
"</div>"
)
def _message_row(message: str) -> SafeHtml:
if not message:
return SafeHtml("")
return SafeHtml(f'<tr><td class="info-label">失败原因</td><td>{_email_text(message)}</td></tr>')
def _check_in_guidance_section(success: bool, is_token_error: bool) -> SafeHtml:
if success:
return SafeHtml("<p>如有问题,请及时检查您的打卡配置。</p>")
dashboard_url = _email_text(_frontend_path("/dashboard"))
if is_token_error:
status_html = (
'<div class="status-box" style="--accent: #b91c1c; --surface: #fef2f2;">'
'<p class="status-title">打卡凭证已过期</p>'
"<p>打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请进入网页处理。</p>"
"</div>"
)
else:
status_html = "<p>请进入网页检查任务配置和最近记录。</p>"
return SafeHtml(
status_html + '<div class="action-wrap">'
f'<a href="{dashboard_url}" class="action-link" style="--accent: #b91c1c;">立即登录刷新</a>'
"</div>"
)
def _is_token_error(success: bool, message: str) -> bool:
if success or not message:
return False
return any(
marker in message
for marker in (
"Token",
"token",
"失效",
"授权",
"登录",
)
)
class EmailService: class EmailService:
"""邮件业务服务(高级服务)""" """邮件业务服务(高级服务)"""
@@ -51,127 +134,28 @@ class EmailService:
Returns: Returns:
是否发送成功 是否发送成功
""" """
# 查询所有管理员邮箱
admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all() admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all()
# 使用 str() 转换避免类型检查问题,并过滤空值
admin_emails: List[str] = [] admin_emails: List[str] = []
for admin in admins: for admin in admins:
email_value = admin.email email_value = admin.email
if email_value is not None: # 使用 is not None 避免布尔转换 if email_value is not None:
admin_emails.append(str(email_value)) admin_emails.append(str(email_value))
if not admin_emails: if not admin_emails:
logger.warning("没有找到管理员邮箱,无法发送通知") logger.warning("没有找到管理员邮箱,无法发送通知")
return False return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}" subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}"
body_html = render_email_template(
# 安全获取创建时间 EmailTemplate.NEW_USER_REGISTRATION,
created_at_value = user.created_at {
created_time = ( "user_alias": user.alias,
created_at_value.strftime("%Y-%m-%d %H:%M:%S") "user_id": user.id,
if created_at_value is not None "created_time": _format_datetime(user.created_at),
else "未知" "admin_url": _frontend_path("/admin/users"),
},
) )
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #667eea;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 10px;
margin: 15px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔔 新用户注册通知</h2>
</div>
<div class="content">
<p>尊敬的管理员,</p>
<p>有新用户注册了接龙自动打卡系统,请及时审批。</p>
<table class="info-table">
<tr>
<td>用户名</td>
<td>{user.alias}</td>
</tr>
<tr>
<td>用户 ID</td>
<td>{user.id}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{created_time}</td>
</tr>
</table>
<div class="warning">
<strong>⚠️ 重要提示:</strong>
<p>该用户需要在 24 小时内通过审批,否则账户将被自动删除。</p>
<p>请登录管理后台进行审批操作。</p>
</div>
<p>登录地址:<a href="{settings.FRONTEND_URL}/admin/users">{settings.FRONTEND_URL}/admin/users</a></p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email(admin_emails, subject, body_html) return EmailService.send_email(admin_emails, subject, body_html)
@staticmethod @staticmethod
@@ -190,134 +174,18 @@ class EmailService:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知") logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
return False return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}" subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
body_html = render_email_template(
# 安全获取创建时间 EmailTemplate.USER_APPROVED,
user_created_at = user.created_at {
created_time = ( "user_alias": user.alias,
user_created_at.strftime("%Y-%m-%d %H:%M:%S") if user_created_at is not None else "未知" "user_role": user.role,
"created_time": _format_datetime(user.created_at),
"approved_time": _now_text(),
"login_url": _frontend_path("/login"),
},
) )
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #28a745;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.success-box {{
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 15px;
margin: 15px 0;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🎉 恭喜!账户审批通过</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>恭喜您的账户已通过管理员审批,现在可以使用所有功能了。</p>
<div class="success-box">
<strong>✅ 审批结果:</strong> 已通过
<br>
<strong>审批时间:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
</div>
<table class="info-table">
<tr>
<td>用户名</td>
<td>{user.alias}</td>
</tr>
<tr>
<td>账户角色</td>
<td>{user.role}</td>
</tr>
<tr>
<td>注册时间</td>
<td>{created_time}</td>
</tr>
</table>
<p><strong>接下来您可以:</strong></p>
<ul>
<li>登录系统创建自动打卡任务</li>
<li>配置打卡时间和内容</li>
<li>查看打卡记录和统计</li>
</ul>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录</a>
</p>
<p style="color: #666; font-size: 14px;">
💡 <strong>温馨提示:</strong>如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html) return EmailService.send_email([str(user_email)], subject, body_html)
@staticmethod @staticmethod
@@ -337,81 +205,15 @@ class EmailService:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过拒绝通知") logger.info(f"用户 {user.alias} 未设置邮箱,跳过拒绝通知")
return False return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】账户审批结果 - {user.alias}" subject = f"【接龙自动打卡系统】账户审批结果 - {user.alias}"
body_html = render_email_template(
reason_html = f"<p><strong>拒绝原因:</strong>{reason}</p>" if reason else "" EmailTemplate.USER_REJECTED,
{
body_html = f""" "user_alias": user.alias,
<!DOCTYPE html> "processed_time": _now_text(),
<html> "reason_section": _optional_reason_section(reason),
<head> },
<meta charset="utf-8"> )
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #dc3545;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>账户审批结果通知</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>很遗憾,您的账户注册申请未能通过审批。</p>
<div class="error-box">
<strong>❌ 审批结果:</strong> 未通过
<br>
<strong>处理时间:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
</div>
{reason_html}
<p>如有疑问,请联系系统管理员。</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html) return EmailService.send_email([str(user_email)], subject, body_html)
@@ -432,104 +234,17 @@ class EmailService:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知") logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
return False return False
# 计算剩余时间
from backend.utils.time_helpers import parse_jwt_exp, minutes_until_expiry
exp_timestamp = parse_jwt_exp(jwt_exp) exp_timestamp = parse_jwt_exp(jwt_exp)
minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0 minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0
# 构建邮件内容
subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}" subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}"
body_html = render_email_template(
body_html = f""" EmailTemplate.TOKEN_EXPIRING,
<!DOCTYPE html> {
<html> "user_alias": user.alias,
<head> "minutes_left": minutes_left,
<meta charset="utf-8"> },
<style> )
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #ff9800;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.warning-box {{
background-color: #fff3cd;
border-left: 4px solid #ff9800;
padding: 15px;
margin: 15px 0;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>⚠️ 登录凭证即将过期</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的 QQ 登录凭证即将在 <strong>{minutes_left} 分钟</strong>后过期。</p>
<div class="warning-box">
<strong>⚠️ 重要提示:</strong>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>登录凭证过期后,系统将无法自动执行您的打卡任务</li>
<li>建议尽快登录系统刷新凭证</li>
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
</ul>
</div>
<p><strong>如何刷新凭证:</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>在个人设置旁的按钮中进行刷新 Token</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html) return EmailService.send_email([str(user_email)], subject, body_html)
@@ -549,98 +264,14 @@ class EmailService:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 已过期通知") logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 已过期通知")
return False return False
# 构建邮件内容
subject = f"【接龙自动打卡系统】登录凭证已过期 - {user.alias}" subject = f"【接龙自动打卡系统】登录凭证已过期 - {user.alias}"
body_html = render_email_template(
body_html = f""" EmailTemplate.TOKEN_EXPIRED,
<!DOCTYPE html> {
<html> "user_alias": user.alias,
<head> "login_url": _frontend_path("/login"),
<meta charset="utf-8"> },
<style> )
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: #dc3545;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>❌ 登录凭证已过期</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。</p>
<div class="error-box">
<strong>⚠️ 重要提示:</strong>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>登录凭证已过期,所有自动打卡任务已暂停</li>
<li>请尽快登录系统刷新凭证以恢复服务</li>
<li>如果您已设置密码,可以使用密码登录后扫码刷新凭证</li>
</ul>
</div>
<p><strong>如何刷新 Token</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>在个人设置旁的按钮中进行刷新 Token</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center;">
<a href="{settings.FRONTEND_URL}/login" class="btn">立即登录刷新</a>
</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html) return EmailService.send_email([str(user_email)], subject, body_html)
@@ -665,145 +296,22 @@ class EmailService:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知") logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
return False return False
# 构建邮件内容 status_text = "成功" if success else "失败"
status_text = "✅ 成功" if success else "❌ 失败" status_color = "#15803d" if success else "#b91c1c"
status_color = "#28a745" if success else "#dc3545" subject = f"【接龙自动打卡】打卡{'✅ 成功' if success else '❌ 失败'} - {user.alias}"
token_error = _is_token_error(success, message)
subject = f"【接龙自动打卡】打卡{status_text} - {user.alias}" body_html = render_email_template(
EmailTemplate.CHECK_IN_RESULT,
# 判断是否是 Token 失效导致的失败 {
is_token_error = ( "user_alias": user.alias,
not success "executed_time": _now_text(),
and message "thread_id": task_info.get("thread_id", "未知"),
and ( "status_text": status_text,
"Token" in message "status_color": status_color,
or "token" in message "message_row": _message_row(message),
or "失效" in message "guidance_section": _check_in_guidance_section(success, token_error),
or "授权" in message },
or "登录" in message
) )
)
# Token 失效时的额外提示内容
token_error_section = ""
if is_token_error:
token_error_section = f"""
<div class="error-box">
<strong>⚠️ 打卡凭证已过期</strong>
<p style="margin: 10px 0;">打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请尽快刷新 Token 以恢复服务。</p>
</div>
<p><strong>如何刷新 Token</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>登录系统(扫码或密码登录)</li>
<li>进入"仪表盘"或点击右上角的"刷新 Token"按钮</li>
<li>使用手机 QQ 扫描二维码完成刷新</li>
</ol>
<p style="text-align: center; margin-top: 20px;">
<a href="{settings.FRONTEND_URL}/dashboard" class="btn">立即登录刷新</a>
</p>
"""
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background-color: {status_color};
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}}
.content {{
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-radius: 0 0 5px 5px;
}}
.info-table {{
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}}
.info-table td {{
padding: 10px;
border-bottom: 1px solid #ddd;
}}
.info-table td:first-child {{
font-weight: bold;
width: 120px;
}}
.footer {{
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}}
.error-box {{
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 15px 0;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 10px 0;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>打卡通知 {status_text}</h2>
</div>
<div class="content">
<p>您好,{user.alias}</p>
<p>您的接龙自动打卡任务已执行。</p>
<table class="info-table">
<tr>
<td>执行时间</td>
<td>{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</td>
</tr>
<tr>
<td>任务 ID</td>
<td>{task_info.get("thread_id", "未知")}</td>
</tr>
<tr>
<td>打卡状态</td>
<td><strong style="color: {status_color};">{status_text}</strong></td>
</tr>
{f"<tr><td>失败原因</td><td>{message}</td></tr>" if message else ""}
</table>
{token_error_section if is_token_error else "<p>如有问题,请及时检查您的打卡配置。</p>"}
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿直接回复。</p>
<p>接龙自动打卡系统 © {datetime.now().year}</p>
</div>
</div>
</body>
</html>
"""
return EmailService.send_email([str(user_email)], subject, body_html) return EmailService.send_email([str(user_email)], subject, body_html)
+13
View File
@@ -0,0 +1,13 @@
from backend.email_templates import (
EmailTemplate,
EmailTemplateRenderError,
SafeHtml,
render_email_template,
)
__all__ = [
"EmailTemplate",
"EmailTemplateRenderError",
"SafeHtml",
"render_email_template",
]
+3
View File
@@ -46,6 +46,9 @@ where = ["apps"]
include = ["backend*"] include = ["backend*"]
namespaces = true namespaces = true
[tool.setuptools.package-data]
"backend.email_templates" = ["templates/*.html"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py312" target-version = "py312"
+317
View File
@@ -0,0 +1,317 @@
from __future__ import annotations
from datetime import datetime
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.models import Base, User
from backend.services.email_service import EmailService
from backend.services.email_templates import (
EmailTemplate,
EmailTemplateRenderError,
SafeHtml,
render_email_template,
)
def test_registration_template_escapes_dynamic_values_and_uses_shared_layout() -> None:
html = render_email_template(
EmailTemplate.NEW_USER_REGISTRATION,
{
"user_alias": "<Alice>",
"user_id": "42",
"created_time": "2026-05-05 10:00:00",
"admin_url": "https://example.test/admin/users?x=1&y=2",
},
)
assert '<div class="email-shell"' in html
assert "新用户注册通知" in html
assert "&lt;Alice&gt;" in html
assert "<Alice>" not in html
assert "https://example.test/admin/users?x=1&amp;y=2" in html
def test_template_renderer_fails_clearly_when_context_is_missing() -> None:
with pytest.raises(EmailTemplateRenderError, match="created_time"):
render_email_template(
EmailTemplate.NEW_USER_REGISTRATION,
{
"user_alias": "Alice",
"user_id": "42",
"admin_url": "https://example.test/admin/users",
},
)
def test_template_renderer_allows_trusted_html_snippets() -> None:
html = render_email_template(
EmailTemplate.USER_REJECTED,
{
"user_alias": "Alice",
"processed_time": "2026-05-05 10:00:00",
"reason_section": SafeHtml("<p><strong>拒绝原因:</strong>资料不完整</p>"),
},
)
assert "<strong>拒绝原因:</strong>资料不完整" in html
def test_template_renderer_defaults_footer_year_to_current_year() -> None:
html = render_email_template(
EmailTemplate.TOKEN_EXPIRED,
{
"user_alias": "Alice",
"login_url": "https://example.test/login",
},
)
assert str(datetime.now().year) in html
@pytest.mark.parametrize(
("template", "context", "expected"),
[
(
EmailTemplate.USER_APPROVED,
{
"user_alias": "Alice",
"user_role": "user",
"created_time": "2026-05-01 09:00:00",
"approved_time": "2026-05-05 10:00:00",
"login_url": "https://example.test/login",
},
"账户审批通过",
),
(
EmailTemplate.USER_REJECTED,
{
"user_alias": "Alice",
"processed_time": "2026-05-05 10:00:00",
"reason_section": "<p><strong>拒绝原因:</strong>资料不完整</p>",
},
"未通过",
),
(
EmailTemplate.TOKEN_EXPIRING,
{
"user_alias": "Alice",
"minutes_left": "25",
},
"25 分钟",
),
(
EmailTemplate.TOKEN_EXPIRED,
{
"user_alias": "Alice",
"login_url": "https://example.test/login",
},
"登录凭证已过期",
),
(
EmailTemplate.CHECK_IN_RESULT,
{
"user_alias": "Alice",
"executed_time": "2026-05-05 10:00:00",
"thread_id": "thread-1",
"status_text": "失败",
"status_color": "#b91c1c",
"message_row": "<tr><td>失败原因</td><td>Token 失效</td></tr>",
"guidance_section": "<p>请刷新 Token。</p>",
},
"thread-1",
),
],
)
def test_supported_notification_templates_render_shared_layout(
template: EmailTemplate, context: dict[str, str], expected: str
) -> None:
html = render_email_template(template, context)
assert '<div class="email-shell"' in html
assert expected in html
assert "接龙自动打卡系统" in html
@pytest.fixture()
def db_session():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(bind=engine)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = Session()
try:
yield session
finally:
session.close()
engine.dispose()
def add_user(db_session, alias: str, email: str | None = None, role: str = "user") -> User:
user = User(alias=alias, email=email, role=role)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
def test_new_user_registration_notification_uses_template_and_admin_recipients(
db_session, monkeypatch
) -> None:
admin = add_user(db_session, "admin", "admin@example.test", "admin")
user = add_user(db_session, "<Alice>", "alice@example.test")
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
sent["subject"] = subject
sent["body_html"] = body_html
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
assert EmailService.notify_new_user_registration(user, db_session) is True
assert sent["to_emails"] == [admin.email]
assert "新用户注册通知" in str(sent["subject"])
assert "&lt;Alice&gt;" in str(sent["body_html"])
assert "<Alice>" not in str(sent["body_html"])
assert "/admin/users" in str(sent["body_html"])
def test_user_rejection_notification_escapes_reason_and_skips_missing_email(monkeypatch) -> None:
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
sent["subject"] = subject
sent["body_html"] = body_html
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
assert EmailService.notify_user_rejected(User(alias="NoMail"), "ignored") is False
assert sent == {}
user = User(alias="Bob", email="bob@example.test")
assert EmailService.notify_user_rejected(user, "<script>alert(1)</script>") is True
assert sent["to_emails"] == ["bob@example.test"]
assert "账户审批结果" in str(sent["subject"])
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in str(sent["body_html"])
assert "<script>" not in str(sent["body_html"])
def test_user_approval_notification_uses_template_login_link(monkeypatch) -> None:
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
sent["subject"] = subject
sent["body_html"] = body_html
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
assert (
EmailService.notify_user_approved(User(alias="Alice", email="alice@example.test")) is True
)
assert sent["to_emails"] == ["alice@example.test"]
assert "账户审批通过" in str(sent["subject"])
assert "立即登录" in str(sent["body_html"])
assert "/login" in str(sent["body_html"])
def test_token_notifications_use_template_links(monkeypatch) -> None:
sent_messages: list[dict[str, object]] = []
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent_messages.append(
{
"to_emails": to_emails,
"subject": subject,
"body_html": body_html,
}
)
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
monkeypatch.setattr(
"backend.services.email_service.parse_jwt_exp",
lambda jwt_exp: 1000,
raising=False,
)
monkeypatch.setattr(
"backend.services.email_service.minutes_until_expiry",
lambda exp_timestamp: 25,
raising=False,
)
user = User(alias="Alice", email="alice@example.test")
assert EmailService.notify_token_expiring(user, "1000") is True
assert EmailService.notify_token_expired(user) is True
assert len(sent_messages) == 2
assert "登录凭证即将过期" in str(sent_messages[0]["subject"])
assert "25 分钟" in str(sent_messages[0]["body_html"])
assert "立即登录刷新" not in str(sent_messages[0]["body_html"])
assert "登录凭证已过期" in str(sent_messages[1]["subject"])
assert "立即登录刷新" in str(sent_messages[1]["body_html"])
def test_check_in_result_notification_preserves_token_error_guidance(monkeypatch) -> None:
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
sent["subject"] = subject
sent["body_html"] = body_html
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
user = User(alias="Alice", email="alice@example.test")
assert (
EmailService.notify_check_in_result(
user,
{"thread_id": "thread-1<script>"},
False,
"Token <expired>",
)
is True
)
assert sent["to_emails"] == ["alice@example.test"]
assert "打卡❌ 失败" in str(sent["subject"])
assert "thread-1&lt;script&gt;" in str(sent["body_html"])
assert "Token &lt;expired&gt;" in str(sent["body_html"])
assert "打卡凭证已过期" in str(sent["body_html"])
assert "立即登录刷新" in str(sent["body_html"])
assert "/dashboard" in str(sent["body_html"])
def test_failed_check_in_notification_includes_dashboard_button(monkeypatch) -> None:
sent: dict[str, object] = {}
def fake_send(to_emails: list[str], subject: str, body_html: str) -> bool:
sent["to_emails"] = to_emails
sent["subject"] = subject
sent["body_html"] = body_html
return True
monkeypatch.setattr(EmailService, "send_email", fake_send)
assert (
EmailService.notify_check_in_result(
User(alias="Alice", email="alice@example.test"),
{"thread_id": "thread-2"},
False,
"网络错误",
)
is True
)
assert "打卡❌ 失败" in str(sent["subject"])
assert "立即登录刷新" in str(sent["body_html"])
assert "/dashboard" in str(sent["body_html"])