尊敬的管理员,
-有新用户注册了接龙自动打卡系统,请及时审批。
- -| 用户名 | -{user.alias} | -
| 用户 ID | -{user.id} | -
| 注册时间 | -{created_time} | -
该用户需要在 24 小时内通过审批,否则账户将被自动删除。
-请登录管理后台进行审批操作。
-diff --git a/apps/backend/email_templates/__init__.py b/apps/backend/email_templates/__init__.py new file mode 100644 index 0000000..689baa9 --- /dev/null +++ b/apps/backend/email_templates/__init__.py @@ -0,0 +1,13 @@ +from backend.email_templates.renderer import ( + EmailTemplate, + EmailTemplateRenderError, + SafeHtml, + render_email_template, +) + +__all__ = [ + "EmailTemplate", + "EmailTemplateRenderError", + "SafeHtml", + "render_email_template", +] diff --git a/apps/backend/email_templates/renderer.py b/apps/backend/email_templates/renderer.py new file mode 100644 index 0000000..c047eaf --- /dev/null +++ b/apps/backend/email_templates/renderer.py @@ -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), + }, + ) diff --git a/apps/backend/email_templates/templates/base.html b/apps/backend/email_templates/templates/base.html new file mode 100644 index 0000000..0ff4ddc --- /dev/null +++ b/apps/backend/email_templates/templates/base.html @@ -0,0 +1,153 @@ + + +
+ + + + +${subtitle}
+您好,${user_alias}!
+您的接龙自动打卡任务已执行。
+ +| 执行时间 | +${executed_time} | +
| 任务 ID | +${thread_id} | +
| 打卡状态 | +${status_text} | +
尊敬的管理员,
+有新用户注册了接龙自动打卡系统,请及时审批。
+ +| 用户名 | +${user_alias} | +
| 用户 ID | +${user_id} | +
| 注册时间 | +${created_time} | +
重要提示
+该用户需要在 24 小时内通过审批,否则账户将被自动删除。
+请登录管理后台进行审批操作。
+管理地址:${admin_url}
diff --git a/apps/backend/email_templates/templates/token-expired.html b/apps/backend/email_templates/templates/token-expired.html new file mode 100644 index 0000000..c36c48d --- /dev/null +++ b/apps/backend/email_templates/templates/token-expired.html @@ -0,0 +1,22 @@ +您好,${user_alias}!
+您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。
+ +重要提示
+如何刷新 Token:
+您好,${user_alias}!
+您的 QQ 登录凭证即将在 ${minutes_left} 分钟后过期。
+ +重要提示
+这封邮件仅用于提前提醒凭证状态,实际处理入口会出现在打卡失败通知中。
diff --git a/apps/backend/email_templates/templates/user-approved.html b/apps/backend/email_templates/templates/user-approved.html new file mode 100644 index 0000000..41461e9 --- /dev/null +++ b/apps/backend/email_templates/templates/user-approved.html @@ -0,0 +1,35 @@ +您好,${user_alias}!
+恭喜您的账户已通过管理员审批,现在可以使用所有功能了。
+ +审批结果:已通过
+审批时间:${approved_time}
+| 用户名 | +${user_alias} | +
| 账户角色 | +${user_role} | +
| 注册时间 | +${created_time} | +
接下来您可以:
+如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。
diff --git a/apps/backend/email_templates/templates/user-rejected.html b/apps/backend/email_templates/templates/user-rejected.html new file mode 100644 index 0000000..c5fe33b --- /dev/null +++ b/apps/backend/email_templates/templates/user-rejected.html @@ -0,0 +1,11 @@ +您好,${user_alias}!
+很遗憾,您的账户注册申请未能通过审批。
+ +审批结果:未通过
+处理时间:${processed_time}
+如有疑问,请联系系统管理员。
diff --git a/apps/backend/services/email_service.py b/apps/backend/services/email_service.py index e165459..8aeb7ad 100644 --- a/apps/backend/services/email_service.py +++ b/apps/backend/services/email_service.py @@ -11,16 +11,99 @@ import logging from datetime import datetime -from typing import List +from html import escape +from typing import Any, List + from sqlalchemy.orm import Session -from backend.models import User -from backend.workers.email_notifier import EmailNotifier 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__) +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( + '拒绝原因
' + f"{_email_text(reason)}
" + "如有问题,请及时检查您的打卡配置。
") + + dashboard_url = _email_text(_frontend_path("/dashboard")) + if is_token_error: + status_html = ( + '打卡凭证已过期
' + "打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请进入网页处理。
" + "请进入网页检查任务配置和最近记录。
" + + return SafeHtml( + status_html + '尊敬的管理员,
-有新用户注册了接龙自动打卡系统,请及时审批。
- -| 用户名 | -{user.alias} | -
| 用户 ID | -{user.id} | -
| 注册时间 | -{created_time} | -
该用户需要在 24 小时内通过审批,否则账户将被自动删除。
-请登录管理后台进行审批操作。
-您好,{user.alias}!
-恭喜您的账户已通过管理员审批,现在可以使用所有功能了。
- -| 用户名 | -{user.alias} | -
| 账户角色 | -{user.role} | -
| 注册时间 | -{created_time} | -
接下来您可以:
-- 立即登录 -
- -- 💡 温馨提示:如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。 -
-拒绝原因:{reason}
" if reason else "" - - body_html = f""" - - - - - - - -您好,{user.alias}!
-很遗憾,您的账户注册申请未能通过审批。
- -如有疑问,请联系系统管理员。
-您好,{user.alias}!
-您的 QQ 登录凭证即将在 {minutes_left} 分钟后过期。
- -如何刷新凭证:
-- 立即登录刷新 -
-您好,{user.alias}!
-您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。
- -如何刷新 Token:
-- 立即登录刷新 -
-打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请尽快刷新 Token 以恢复服务。
-如何刷新 Token:
-- 立即登录刷新 -
- """ - - body_html = f""" - - - - - - - -您好,{user.alias}!
-您的接龙自动打卡任务已执行。
- -| 执行时间 | -{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | -
| 任务 ID | -{task_info.get("thread_id", "未知")} | -
| 打卡状态 | -{status_text} | -
| 失败原因 | {message} |
如有问题,请及时检查您的打卡配置。
"} -拒绝原因:资料不完整
"), + }, + ) + + assert "拒绝原因:资料不完整" 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": "拒绝原因:资料不完整
", + }, + "未通过", + ), + ( + 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": "请刷新 Token。
", + }, + "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 '