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 @@ + + + + + + + +
+
+ + + +
+
+ + diff --git a/apps/backend/email_templates/templates/check-in-result.html b/apps/backend/email_templates/templates/check-in-result.html new file mode 100644 index 0000000..13bb32f --- /dev/null +++ b/apps/backend/email_templates/templates/check-in-result.html @@ -0,0 +1,20 @@ +

您好,${user_alias}!

+

您的接龙自动打卡任务已执行。

+ + + + + + + + + + + + + + + ${message_row} +
执行时间${executed_time}
任务 ID${thread_id}
打卡状态${status_text}
+ +${guidance_section} diff --git a/apps/backend/email_templates/templates/new-user-registration.html b/apps/backend/email_templates/templates/new-user-registration.html new file mode 100644 index 0000000..48d0227 --- /dev/null +++ b/apps/backend/email_templates/templates/new-user-registration.html @@ -0,0 +1,29 @@ +

尊敬的管理员,

+

有新用户注册了接龙自动打卡系统,请及时审批。

+ + + + + + + + + + + + + + +
用户名${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:

+
    +
  1. 登录系统(扫码或密码登录)
  2. +
  3. 在个人设置旁的按钮中进行刷新 Token
  4. +
  5. 使用手机 QQ 扫描二维码完成刷新
  6. +
+ +
+ 立即登录刷新 +
diff --git a/apps/backend/email_templates/templates/token-expiring.html b/apps/backend/email_templates/templates/token-expiring.html new file mode 100644 index 0000000..81920d4 --- /dev/null +++ b/apps/backend/email_templates/templates/token-expiring.html @@ -0,0 +1,13 @@ +

您好,${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}

+
+ +${reason_section} + +

如有疑问,请联系系统管理员。

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)}

" + "
" + ) + + +def _message_row(message: str) -> SafeHtml: + if not message: + return SafeHtml("") + + return SafeHtml(f'失败原因{_email_text(message)}') + + +def _check_in_guidance_section(success: bool, is_token_error: bool) -> SafeHtml: + if success: + return SafeHtml("

如有问题,请及时检查您的打卡配置。

") + + dashboard_url = _email_text(_frontend_path("/dashboard")) + if is_token_error: + status_html = ( + '
' + '

打卡凭证已过期

' + "

打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请进入网页处理。

" + "
" + ) + else: + status_html = "

请进入网页检查任务配置和最近记录。

" + + return SafeHtml( + status_html + '
' + f'立即登录刷新' + "
" + ) + + +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: """邮件业务服务(高级服务)""" @@ -51,127 +134,28 @@ class EmailService: Returns: 是否发送成功 """ - # 查询所有管理员邮箱 admins = db.query(User).filter(User.role == "admin", User.email.isnot(None)).all() - # 使用 str() 转换避免类型检查问题,并过滤空值 admin_emails: List[str] = [] for admin in admins: 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)) if not admin_emails: logger.warning("没有找到管理员邮箱,无法发送通知") return False - # 构建邮件内容 subject = f"【接龙自动打卡系统】新用户注册通知 - {user.alias}" - - # 安全获取创建时间 - created_at_value = user.created_at - created_time = ( - created_at_value.strftime("%Y-%m-%d %H:%M:%S") - if created_at_value is not None - else "未知" + body_html = render_email_template( + EmailTemplate.NEW_USER_REGISTRATION, + { + "user_alias": user.alias, + "user_id": user.id, + "created_time": _format_datetime(user.created_at), + "admin_url": _frontend_path("/admin/users"), + }, ) - body_html = f""" - - - - - - - -
-
-

🔔 新用户注册通知

-
-
-

尊敬的管理员,

-

有新用户注册了接龙自动打卡系统,请及时审批。

- - - - - - - - - - - - - - -
用户名{user.alias}
用户 ID{user.id}
注册时间{created_time}
- -
- ⚠️ 重要提示: -

该用户需要在 24 小时内通过审批,否则账户将被自动删除。

-

请登录管理后台进行审批操作。

-
- -

登录地址:{settings.FRONTEND_URL}/admin/users

-
- -
- - - """ - return EmailService.send_email(admin_emails, subject, body_html) @staticmethod @@ -190,134 +174,18 @@ class EmailService: logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知") return False - # 构建邮件内容 subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}" - - # 安全获取创建时间 - user_created_at = user.created_at - created_time = ( - user_created_at.strftime("%Y-%m-%d %H:%M:%S") if user_created_at is not None else "未知" + body_html = render_email_template( + EmailTemplate.USER_APPROVED, + { + "user_alias": user.alias, + "user_role": user.role, + "created_time": _format_datetime(user.created_at), + "approved_time": _now_text(), + "login_url": _frontend_path("/login"), + }, ) - body_html = f""" - - - - - - - -
-
-

🎉 恭喜!账户审批通过

-
-
-

您好,{user.alias}!

-

恭喜您的账户已通过管理员审批,现在可以使用所有功能了。

- -
- ✅ 审批结果: 已通过 -
- 审批时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} -
- - - - - - - - - - - - - - -
用户名{user.alias}
账户角色{user.role}
注册时间{created_time}
- -

接下来您可以:

- - -

- 立即登录 -

- -

- 💡 温馨提示:如果您还没有设置密码,建议在个人设置中设置密码,方便后续登录。 -

-
- -
- - - """ - return EmailService.send_email([str(user_email)], subject, body_html) @staticmethod @@ -337,81 +205,15 @@ class EmailService: logger.info(f"用户 {user.alias} 未设置邮箱,跳过拒绝通知") return False - # 构建邮件内容 subject = f"【接龙自动打卡系统】账户审批结果 - {user.alias}" - - reason_html = f"

拒绝原因:{reason}

" if reason else "" - - body_html = f""" - - - - - - - -
-
-

账户审批结果通知

-
-
-

您好,{user.alias}!

-

很遗憾,您的账户注册申请未能通过审批。

- -
- ❌ 审批结果: 未通过 -
- 处理时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} -
- - {reason_html} - -

如有疑问,请联系系统管理员。

-
- -
- - - """ + body_html = render_email_template( + EmailTemplate.USER_REJECTED, + { + "user_alias": user.alias, + "processed_time": _now_text(), + "reason_section": _optional_reason_section(reason), + }, + ) return EmailService.send_email([str(user_email)], subject, body_html) @@ -432,104 +234,17 @@ class EmailService: logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知") return False - # 计算剩余时间 - from backend.utils.time_helpers import parse_jwt_exp, minutes_until_expiry - exp_timestamp = parse_jwt_exp(jwt_exp) minutes_left = minutes_until_expiry(exp_timestamp) if exp_timestamp else 0 - # 构建邮件内容 subject = f"【接龙自动打卡系统】登录凭证即将过期 - {user.alias}" - - body_html = f""" - - - - - - - -
-
-

⚠️ 登录凭证即将过期

-
-
-

您好,{user.alias}!

-

您的 QQ 登录凭证即将在 {minutes_left} 分钟后过期。

- -
- ⚠️ 重要提示: -
    -
  • 登录凭证过期后,系统将无法自动执行您的打卡任务
  • -
  • 建议尽快登录系统刷新凭证
  • -
  • 如果您已设置密码,可以使用密码登录后扫码刷新凭证
  • -
-
- -

如何刷新凭证:

-
    -
  1. 登录系统(扫码或密码登录)
  2. -
  3. 在个人设置旁的按钮中进行刷新 Token
  4. -
  5. 使用手机 QQ 扫描二维码完成刷新
  6. -
- -

- 立即登录刷新 -

-
- -
- - - """ + body_html = render_email_template( + EmailTemplate.TOKEN_EXPIRING, + { + "user_alias": user.alias, + "minutes_left": minutes_left, + }, + ) return EmailService.send_email([str(user_email)], subject, body_html) @@ -549,98 +264,14 @@ class EmailService: logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 已过期通知") return False - # 构建邮件内容 subject = f"【接龙自动打卡系统】登录凭证已过期 - {user.alias}" - - body_html = f""" - - - - - - - -
-
-

❌ 登录凭证已过期

-
-
-

您好,{user.alias}!

-

您的 QQ 登录凭证已过期,系统已无法自动执行打卡任务。

- -
- ⚠️ 重要提示: -
    -
  • 登录凭证已过期,所有自动打卡任务已暂停
  • -
  • 请尽快登录系统刷新凭证以恢复服务
  • -
  • 如果您已设置密码,可以使用密码登录后扫码刷新凭证
  • -
-
- -

如何刷新 Token:

-
    -
  1. 登录系统(扫码或密码登录)
  2. -
  3. 在个人设置旁的按钮中进行刷新 Token
  4. -
  5. 使用手机 QQ 扫描二维码完成刷新
  6. -
- -

- 立即登录刷新 -

-
- -
- - - """ + body_html = render_email_template( + EmailTemplate.TOKEN_EXPIRED, + { + "user_alias": user.alias, + "login_url": _frontend_path("/login"), + }, + ) return EmailService.send_email([str(user_email)], subject, body_html) @@ -665,145 +296,22 @@ class EmailService: logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知") return False - # 构建邮件内容 - status_text = "✅ 成功" if success else "❌ 失败" - status_color = "#28a745" if success else "#dc3545" + status_text = "成功" if success else "失败" + status_color = "#15803d" if success else "#b91c1c" + subject = f"【接龙自动打卡】打卡{'✅ 成功' if success else '❌ 失败'} - {user.alias}" + token_error = _is_token_error(success, message) - subject = f"【接龙自动打卡】打卡{status_text} - {user.alias}" - - # 判断是否是 Token 失效导致的失败 - is_token_error = ( - not success - and message - and ( - "Token" in message - or "token" in message - or "失效" in message - or "授权" in message - or "登录" in message - ) + body_html = render_email_template( + EmailTemplate.CHECK_IN_RESULT, + { + "user_alias": user.alias, + "executed_time": _now_text(), + "thread_id": task_info.get("thread_id", "未知"), + "status_text": status_text, + "status_color": status_color, + "message_row": _message_row(message), + "guidance_section": _check_in_guidance_section(success, token_error), + }, ) - # Token 失效时的额外提示内容 - token_error_section = "" - if is_token_error: - token_error_section = f""" -
- ⚠️ 打卡凭证已过期 -

打卡凭证已过期,无法自动打卡。所有自动打卡任务已暂停,请尽快刷新 Token 以恢复服务。

-
- -

如何刷新 Token:

-
    -
  1. 登录系统(扫码或密码登录)
  2. -
  3. 进入"仪表盘"或点击右上角的"刷新 Token"按钮
  4. -
  5. 使用手机 QQ 扫描二维码完成刷新
  6. -
- -

- 立即登录刷新 -

- """ - - body_html = f""" - - - - - - - -
-
-

打卡通知 {status_text}

-
-
-

您好,{user.alias}!

-

您的接龙自动打卡任务已执行。

- - - - - - - - - - - - - - - {f"" if message else ""} -
执行时间{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
任务 ID{task_info.get("thread_id", "未知")}
打卡状态{status_text}
失败原因{message}
- - {token_error_section if is_token_error else "

如有问题,请及时检查您的打卡配置。

"} -
- -
- - - """ - return EmailService.send_email([str(user_email)], subject, body_html) diff --git a/apps/backend/services/email_templates.py b/apps/backend/services/email_templates.py new file mode 100644 index 0000000..101f168 --- /dev/null +++ b/apps/backend/services/email_templates.py @@ -0,0 +1,13 @@ +from backend.email_templates import ( + EmailTemplate, + EmailTemplateRenderError, + SafeHtml, + render_email_template, +) + +__all__ = [ + "EmailTemplate", + "EmailTemplateRenderError", + "SafeHtml", + "render_email_template", +] diff --git a/pyproject.toml b/pyproject.toml index bf09341..6e8a43e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ where = ["apps"] include = ["backend*"] namespaces = true +[tool.setuptools.package-data] +"backend.email_templates" = ["templates/*.html"] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/tests/test_email_notification_templates.py b/tests/test_email_notification_templates.py new file mode 100644 index 0000000..db24ade --- /dev/null +++ b/tests/test_email_notification_templates.py @@ -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": "", + "user_id": "42", + "created_time": "2026-05-05 10:00:00", + "admin_url": "https://example.test/admin/users?x=1&y=2", + }, + ) + + assert '
" 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("

拒绝原因:资料不完整

"), + }, + ) + + 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 失效", + "guidance_section": "

请刷新 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 '