mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(email): extract notification templates
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
from backend.email_templates.renderer import (
|
||||||
|
EmailTemplate,
|
||||||
|
EmailTemplateRenderError,
|
||||||
|
SafeHtml,
|
||||||
|
render_email_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EmailTemplate",
|
||||||
|
"EmailTemplateRenderError",
|
||||||
|
"SafeHtml",
|
||||||
|
"render_email_template",
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from backend.email_templates import (
|
||||||
|
EmailTemplate,
|
||||||
|
EmailTemplateRenderError,
|
||||||
|
SafeHtml,
|
||||||
|
render_email_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EmailTemplate",
|
||||||
|
"EmailTemplateRenderError",
|
||||||
|
"SafeHtml",
|
||||||
|
"render_email_template",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 "<Alice>" in html
|
||||||
|
assert "<Alice>" not in html
|
||||||
|
assert "https://example.test/admin/users?x=1&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 "<Alice>" 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 "<script>alert(1)</script>" 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<script>" in str(sent["body_html"])
|
||||||
|
assert "Token <expired>" 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"])
|
||||||
Reference in New Issue
Block a user