From 6731373ded51e2bd905be40cf84eede2d5420e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= Date: Sun, 12 Oct 2025 00:36:47 +0800 Subject: [PATCH] Add MailerSend and template-based email verification Introduced support for MailerSend as an email provider alongside SMTP, with configuration options in settings. Added Jinja2-based multi-language email templates for verification emails, and refactored the email sending logic to use these templates and support language selection based on user country code. Updated related services and API endpoints to pass country code and handle new response formats. Added dependencies for Jinja2 and MailerSend. --- .gitignore | 1 + app/config.py | 17 +- app/middleware/verify_session.py | 9 +- app/router/auth.py | 1 + app/router/v2/session_verify.py | 24 ++- app/service/email_queue.py | 71 ++++++- app/service/email_template_service.py | 184 +++++++++++++++++ app/service/mailersend_service.py | 87 ++++++++ app/service/verification_service.py | 249 +++++++++-------------- app/templates/email/verification_en.html | 141 +++++++++++++ app/templates/email/verification_en.txt | 18 ++ app/templates/email/verification_zh.html | 141 +++++++++++++ app/templates/email/verification_zh.txt | 18 ++ pyproject.toml | 2 + uv.lock | 86 ++++++++ 15 files changed, 886 insertions(+), 163 deletions(-) create mode 100644 app/service/email_template_service.py create mode 100644 app/service/mailersend_service.py create mode 100644 app/templates/email/verification_en.html create mode 100644 app/templates/email/verification_en.txt create mode 100644 app/templates/email/verification_zh.html create mode 100644 app/templates/email/verification_zh.txt diff --git a/.gitignore b/.gitignore index 6f15ff1..8a2009c 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,4 @@ osu-web-master/.env.testing.example config/* !config/ !config/.gitkeep +osu-web-master/* diff --git a/app/config.py b/app/config.py index a026a76..7f4bd07 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Annotated, Any +from typing import Annotated, Any, Literal from pydantic import ( AliasChoices, @@ -336,6 +336,11 @@ STORAGE_SETTINGS='{ Field(default=30, description="设备信任持续天数"), "验证服务设置", ] + email_provider: Annotated[ + Literal["smtp", "mailersend"], + Field(default="smtp", description="邮件发送提供商:smtp(SMTP)或 mailersend(MailerSend)"), + "验证服务设置", + ] smtp_server: Annotated[ str, Field(default="localhost", description="SMTP 服务器地址"), @@ -366,6 +371,16 @@ STORAGE_SETTINGS='{ Field(default="osu! server", description="发件人名称"), "验证服务设置", ] + mailersend_api_key: Annotated[ + str, + Field(default="", description="MailerSend API Key"), + "验证服务设置", + ] + mailersend_from_email: Annotated[ + str, + Field(default="", description="MailerSend 发件人邮箱(需要在 MailerSend 中验证)"), + "验证服务设置", + ] # 监控配置 sentry_dsn: Annotated[ diff --git a/app/middleware/verify_session.py b/app/middleware/verify_session.py index 626a5e5..3613288 100644 --- a/app/middleware/verify_session.py +++ b/app/middleware/verify_session.py @@ -96,7 +96,14 @@ class SessionState: # 这里可以触发邮件发送 await EmailVerificationService.send_verification_email( - self.db, self.redis, self.user.id, self.user.username, self.user.email, None, None + self.db, + self.redis, + self.user.id, + self.user.username, + self.user.email, + None, + None, + self.user.country_code, ) except Exception as e: logger.error(f"Error issuing mail: {e}") diff --git a/app/router/auth.py b/app/router/auth.py index 28929d8..9e3a568 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -331,6 +331,7 @@ async def oauth_token( user.email, ip_address, user_agent, + user.country_code, ) # 记录需要二次验证的登录尝试 diff --git a/app/router/v2/session_verify.py b/app/router/v2/session_verify.py index cf2808c..d1fecc8 100644 --- a/app/router/v2/session_verify.py +++ b/app/router/v2/session_verify.py @@ -101,7 +101,14 @@ async def verify_session( if settings.enable_email_verification: await LoginSessionService.set_login_method(user_id, token_id, "mail", redis) await EmailVerificationService.send_verification_email( - db, redis, user_id, current_user.username, current_user.email, ip_address, user_agent + db, + redis, + user_id, + current_user.username, + current_user.email, + ip_address, + user_agent, + current_user.country_code, ) verify_method = "mail" raise VerifyFailedError("用户TOTP已被删除,已切换到邮件验证") @@ -162,7 +169,14 @@ async def verify_session( if hasattr(e, "should_reissue") and e.should_reissue and verify_method == "mail": try: await EmailVerificationService.send_verification_email( - db, redis, user_id, current_user.username, current_user.email, ip_address, user_agent + db, + redis, + user_id, + current_user.username, + current_user.email, + ip_address, + user_agent, + current_user.country_code, ) error_response["reissued"] = True except Exception: @@ -203,7 +217,7 @@ async def reissue_verification_code( try: user_id = current_user.id - success, message = await EmailVerificationService.resend_verification_code( + success, message, _ = await EmailVerificationService.resend_verification_code( db, redis, user_id, @@ -211,6 +225,7 @@ async def reissue_verification_code( current_user.email, ip_address, user_agent, + current_user.country_code, ) return SessionReissueResponse(success=success, message=message) @@ -241,7 +256,7 @@ async def fallback_email( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前会话不需要回退") await LoginSessionService.set_login_method(current_user.id, token_id, "mail", redis) - success, message = await EmailVerificationService.resend_verification_code( + success, message, _ = await EmailVerificationService.resend_verification_code( db, redis, current_user.id, @@ -249,6 +264,7 @@ async def fallback_email( current_user.email, ip_address, user_agent, + current_user.country_code, ) if not success: log("Verification").error( diff --git a/app/service/email_queue.py b/app/service/email_queue.py index 97d293e..49ccdab 100644 --- a/app/service/email_queue.py +++ b/app/service/email_queue.py @@ -30,7 +30,10 @@ class EmailQueue: self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=2) self._retry_limit = 3 # 重试次数限制 - # 邮件配置 + # 邮件提供商配置 + self.email_provider = settings.email_provider + + # SMTP 邮件配置 self.smtp_server = settings.smtp_server self.smtp_port = settings.smtp_port self.smtp_username = settings.smtp_username @@ -38,6 +41,9 @@ class EmailQueue: self.from_email = settings.from_email self.from_name = settings.from_name + # MailerSend 服务(延迟初始化) + self._mailersend_service = None + async def _run_in_executor(self, func, *args): """在线程池中运行同步操作""" loop = asyncio.get_event_loop() @@ -212,6 +218,21 @@ class EmailQueue: """ 实际发送邮件 + Args: + email_data: 邮件数据 + + Returns: + 是否发送成功 + """ + if self.email_provider == "mailersend": + return await self._send_email_mailersend(email_data) + else: + return await self._send_email_smtp(email_data) + + async def _send_email_smtp(self, email_data: dict[str, Any]) -> bool: + """ + 使用 SMTP 发送邮件 + Args: email_data: 邮件数据 @@ -248,7 +269,53 @@ class EmailQueue: return True except Exception as e: - logger.error(f"Failed to send email: {e}") + logger.error(f"Failed to send email via SMTP: {e}") + return False + + async def _send_email_mailersend(self, email_data: dict[str, Any]) -> bool: + """ + 使用 MailerSend 发送邮件 + + Args: + email_data: 邮件数据 + + Returns: + 是否发送成功 + """ + try: + # 延迟初始化 MailerSend 服务 + if self._mailersend_service is None: + from app.service.mailersend_service import get_mailersend_service + + self._mailersend_service = get_mailersend_service() + + # 提取邮件数据 + to_email = email_data.get("to_email", "") + subject = email_data.get("subject", "") + content = email_data.get("content", "") + html_content = email_data.get("html_content", "") + metadata_str = email_data.get("metadata", "{}") + metadata = json.loads(metadata_str) if metadata_str else {} + + # 发送邮件 + response = await self._mailersend_service.send_email( + to_email=to_email, + subject=subject, + content=content, + html_content=html_content if html_content else None, + metadata=metadata, + ) + + # 检查响应中是否有 id + if response and response.get("id"): + logger.info(f"Email sent via MailerSend, message_id: {response['id']}") + return True + else: + logger.error("MailerSend response missing 'id'") + return False + + except Exception as e: + logger.error(f"Failed to send email via MailerSend: {e}") return False diff --git a/app/service/email_template_service.py b/app/service/email_template_service.py new file mode 100644 index 0000000..ad911c5 --- /dev/null +++ b/app/service/email_template_service.py @@ -0,0 +1,184 @@ +""" +邮件模板服务 +使用 Jinja2 模板引擎,支持多语言邮件 +""" + +from datetime import datetime +from pathlib import Path +from typing import Any, ClassVar + +from app.config import settings +from app.log import logger + +from jinja2 import Environment, FileSystemLoader, Template + + +class EmailTemplateService: + """邮件模板服务,支持多语言""" + + # 中文国家/地区代码列表 + CHINESE_COUNTRIES: ClassVar[list[str]] = [ + "CN", # 中国大陆 + "TW", # 台湾 + "HK", # 香港 + "MO", # 澳门 + "SG", # 新加坡(有中文使用者) + ] + + def __init__(self): + """初始化 Jinja2 模板引擎""" + # 模板目录路径 + template_dir = Path(__file__).parent.parent / "templates" / "email" + + # 创建 Jinja2 环境 + self.env = Environment( + loader=FileSystemLoader(str(template_dir)), + autoescape=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + logger.info(f"Email template service initialized with template directory: {template_dir}") + + def get_language(self, country_code: str | None) -> str: + """ + 根据国家代码获取语言 + + Args: + country_code: ISO 3166-1 alpha-2 国家代码(如 CN, US) + + Returns: + 语言代码(zh 或 en) + """ + if not country_code: + return "en" + + # 转换为大写 + country_code = country_code.upper() + + # 检查是否是中文国家/地区 + if country_code in self.CHINESE_COUNTRIES: + return "zh" + + return "en" + + def render_template( + self, + template_name: str, + language: str, + context: dict[str, Any], + ) -> str: + """ + 渲染模板 + + Args: + template_name: 模板名称(不含语言后缀和扩展名) + language: 语言代码(zh 或 en) + context: 模板上下文数据 + + Returns: + 渲染后的模板内容 + """ + try: + # 构建模板文件名 + template_file = f"{template_name}_{language}.html" + + # 加载并渲染模板 + template: Template = self.env.get_template(template_file) + return template.render(**context) + + except Exception as e: + logger.error(f"Failed to render template {template_name}_{language}: {e}") + # 如果渲染失败且不是英文,尝试使用英文模板 + if language != "en": + logger.warning(f"Falling back to English template for {template_name}") + return self.render_template(template_name, "en", context) + raise + + def render_text_template( + self, + template_name: str, + language: str, + context: dict[str, Any], + ) -> str: + """ + 渲染纯文本模板 + + Args: + template_name: 模板名称(不含语言后缀和扩展名) + language: 语言代码(zh 或 en) + context: 模板上下文数据 + + Returns: + 渲染后的纯文本内容 + """ + try: + # 构建模板文件名 + template_file = f"{template_name}_{language}.txt" + + # 加载并渲染模板 + template: Template = self.env.get_template(template_file) + return template.render(**context) + + except Exception as e: + logger.error(f"Failed to render text template {template_name}_{language}: {e}") + # 如果渲染失败且不是英文,尝试使用英文模板 + if language != "en": + logger.warning(f"Falling back to English text template for {template_name}") + return self.render_text_template(template_name, "en", context) + raise + + def render_verification_email( + self, + username: str, + code: str, + country_code: str | None = None, + expiry_minutes: int = 10, + ) -> tuple[str, str, str]: + """ + 渲染验证邮件 + + Args: + username: 用户名 + code: 验证码 + country_code: 国家代码 + expiry_minutes: 验证码过期时间(分钟) + + Returns: + (主题, HTML内容, 纯文本内容) + """ + # 获取语言 + language = self.get_language(country_code) + + # 准备模板上下文 + context = { + "username": username, + "code": code, + "expiry_minutes": expiry_minutes, + "server_name": settings.from_name, + "year": datetime.now().year, + } + + # 渲染 HTML 和纯文本模板 + html_content = self.render_template("verification", language, context) + text_content = self.render_text_template("verification", language, context) + + # 根据语言设置主题 + if language == "zh": + subject = f"邮箱验证 - {settings.from_name}" + else: + subject = f"Email Verification - {settings.from_name}" + + return subject, html_content, text_content + + +# 全局邮件模板服务实例 +_email_template_service: EmailTemplateService | None = None + + +def get_email_template_service() -> EmailTemplateService: + """获取或创建邮件模板服务实例""" + global _email_template_service + if _email_template_service is None: + _email_template_service = EmailTemplateService() + return _email_template_service diff --git a/app/service/mailersend_service.py b/app/service/mailersend_service.py new file mode 100644 index 0000000..851a882 --- /dev/null +++ b/app/service/mailersend_service.py @@ -0,0 +1,87 @@ +""" +MailerSend 邮件发送服务 +使用 MailerSend API 发送邮件 +""" + +from typing import Any + +from app.config import settings +from app.log import logger + +from mailersend import EmailBuilder, MailerSendClient + + +class MailerSendService: + """MailerSend 邮件发送服务""" + + def __init__(self): + if not settings.mailersend_api_key: + raise ValueError("MailerSend API Key is required when email_provider is 'mailersend'") + if not settings.mailersend_from_email: + raise ValueError("MailerSend from email is required when email_provider is 'mailersend'") + + self.client = MailerSendClient(api_key=settings.mailersend_api_key) + self.from_email = settings.mailersend_from_email + self.from_name = settings.from_name + + async def send_email( + self, + to_email: str, + subject: str, + content: str, + html_content: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, str]: + """ + 使用 MailerSend 发送邮件 + + Args: + to_email: 收件人邮箱地址 + subject: 邮件主题 + content: 邮件纯文本内容 + html_content: 邮件HTML内容(如果有) + metadata: 额外元数据(未使用) + + Returns: + 返回格式为 {'id': 'message_id'} 的字典 + """ + try: + _ = metadata # 避免未使用参数警告 + + # 构建邮件 + email_builder = EmailBuilder() + email_builder.from_email(self.from_email, self.from_name) + email_builder.to_many([{"email": to_email}]) + email_builder.subject(subject) + + # 优先使用 HTML 内容,否则使用纯文本 + if html_content: + email_builder.html(html_content) + else: + email_builder.text(content) + + email = email_builder.build() + + # 发送邮件 + response = self.client.emails.send(email) + + # 从 APIResponse 中提取 message_id + message_id = getattr(response, "id", "") if response else "" + logger.info(f"Successfully sent email via MailerSend to {to_email}, message_id: {message_id}") + return {"id": message_id} + + except Exception as e: + logger.error(f"Failed to send email via MailerSend: {e}") + return {"id": ""} + + +# 全局 MailerSend 服务实例 +_mailersend_service: MailerSendService | None = None + + +def get_mailersend_service() -> MailerSendService: + """获取或创建 MailerSend 服务实例""" + global _mailersend_service + if _mailersend_service is None: + _mailersend_service = MailerSendService() + return _mailersend_service diff --git a/app/service/verification_service.py b/app/service/verification_service.py index 134f8e5..2fda473 100644 --- a/app/service/verification_service.py +++ b/app/service/verification_service.py @@ -29,7 +29,9 @@ class EmailVerificationService: return "".join(secrets.choice(string.digits) for _ in range(8)) @staticmethod - async def send_verification_email_via_queue(email: str, code: str, username: str, user_id: int) -> bool: + async def send_verification_email_via_queue( + email: str, code: str, username: str, user_id: int, country_code: str | None = None + ) -> dict[str, str]: """使用邮件队列发送验证邮件 Args: @@ -37,149 +39,52 @@ class EmailVerificationService: code: 验证码 username: 用户名 user_id: 用户ID + country_code: 国家代码(用于选择邮件语言) Returns: - 是否成功将邮件加入队列 + 返回格式为 {'id': 'message_id'} 的字典,如果使用 SMTP 则返回 email_id """ try: - # HTML 邮件内容 - html_content = f""" - - - - - - - -
-
-

osu! 邮箱验证

-

Email Verification

-
+ from app.service.email_template_service import get_email_template_service -
-

你好 {username}!

-

请使用以下验证码验证您的账户:

- -
{code}
- -

验证码将在 10 分钟内有效

- -
-

重要提示:

-
    -
  • 请不要与任何人分享此验证码
  • -
  • 如果您没有请求验证码,请忽略此邮件
  • -
  • 为了账户安全,请勿在其他网站使用相同的密码
  • -
-
- -
- -

Hello {username}!

-

Please use the following verification code to verify your account:

- -

This verification code will be valid for 10 minutes.

- -

Important: Do not share this verification code with anyone. If you did not request this code, please ignore this email.

-
- - -
- - - """ # noqa: E501 - - # 纯文本备用内容 - plain_content = f""" -你好 {username}! - -请使用以下验证码验证您的账户: - -{code} - -验证码将在10分钟内有效。 - -重要提示: -- 请不要与任何人分享此验证码 -- 如果您没有请求验证码,请忽略此邮件 -- 为了账户安全,请勿在其他网站使用相同的密码 - -Hello {username}! -Please use the following verification code to verify your account. -This verification code will be valid for 10 minutes. - -© 2025 g0v0! Private Server. 此邮件由系统自动发送,请勿回复。 -This email was sent automatically, please do not reply. -""" - - # 将邮件加入队列 - subject = "邮箱验证 - Email Verification" - metadata = {"type": "email_verification", "user_id": user_id, "code": code} - - await email_queue.enqueue_email( - to_email=email, - subject=subject, - content=plain_content, - html_content=html_content, - metadata=metadata, + # 使用模板服务生成邮件内容 + template_service = get_email_template_service() + subject, html_content, plain_content = template_service.render_verification_email( + username=username, + code=code, + country_code=country_code, + expiry_minutes=10, ) + # 准备元数据 + metadata = {"type": "email_verification", "user_id": user_id, "code": code, "country": country_code} - return True + # 如果使用 MailerSend,直接发送并返回 message_id + if settings.email_provider == "mailersend": + from app.service.mailersend_service import get_mailersend_service + + mailersend_service = get_mailersend_service() + response = await mailersend_service.send_email( + to_email=email, + subject=subject, + content=plain_content, + html_content=html_content, + metadata=metadata, + ) + return response + else: + # 使用 SMTP 队列发送 + email_id = await email_queue.enqueue_email( + to_email=email, + subject=subject, + content=plain_content, + html_content=html_content, + metadata=metadata, + ) + return {"id": email_id} except Exception as e: logger.error(f"Failed to enqueue email: {e}") - return False + return {"id": ""} @staticmethod def generate_session_token() -> str: @@ -247,13 +152,28 @@ This email was sent automatically, please do not reply. email: str, ip_address: str | None = None, user_agent: UserAgentInfo | None = None, - ) -> bool: - """发送验证邮件""" + country_code: str | None = None, + ) -> dict[str, str]: + """发送验证邮件 + + Args: + db: 数据库会话 + redis: Redis 客户端 + user_id: 用户ID + username: 用户名 + email: 邮箱地址 + ip_address: IP 地址 + user_agent: 用户代理信息 + country_code: 国家代码(用于选择邮件语言) + + Returns: + 返回格式为 {'id': 'message_id'} 的字典 + """ try: # 检查是否启用邮件验证功能 if not settings.enable_email_verification: logger.debug(f"Email verification is disabled, skipping for user {user_id}") - return True # 返回成功,但不执行验证流程 + return {"id": "disabled"} # 返回特殊ID表示功能已禁用 # 检测客户端信息 logger.info(f"Detected client for user {user_id}: {user_agent}") @@ -267,18 +187,22 @@ This email was sent automatically, please do not reply. ) # 使用邮件队列发送验证邮件 - success = await EmailVerificationService.send_verification_email_via_queue(email, code, username, user_id) + response = await EmailVerificationService.send_verification_email_via_queue( + email, code, username, user_id, country_code + ) - if success: - logger.info(f"Successfully enqueued verification email to {email} (user: {username})") - return True + if response and response.get("id"): + logger.info( + f"Successfully sent verification email to {email} (user: {username}), message_id: {response['id']}" + ) + return response else: - logger.error(f"Failed to enqueue verification email: {email} (user: {username})") - return False + logger.error(f"Failed to send verification email: {email} (user: {username})") + return {"id": ""} except Exception as e: logger.error(f"Exception during sending verification email: {e}") - return False + return {"id": ""} @staticmethod async def verify_email_code( @@ -339,37 +263,52 @@ This email was sent automatically, please do not reply. email: str, ip_address: str | None = None, user_agent: UserAgentInfo | None = None, - ) -> tuple[bool, str]: - """重新发送验证码""" + country_code: str | None = None, + ) -> tuple[bool, str, dict[str, str]]: + """重新发送验证码 + + Args: + db: 数据库会话 + redis: Redis 客户端 + user_id: 用户ID + username: 用户名 + email: 邮箱地址 + ip_address: IP 地址 + user_agent: 用户代理信息 + country_code: 国家代码(用于选择邮件语言) + + Returns: + (是否成功, 消息, {'id': 'message_id'}) + """ try: # 避免未使用参数警告 _ = user_agent # 检查是否启用邮件验证功能 if not settings.enable_email_verification: logger.debug(f"Email verification is disabled, skipping resend for user {user_id}") - return True, "验证码已发送(邮件验证功能已禁用)" + return True, "验证码已发送(邮件验证功能已禁用)", {"id": "disabled"} # 检查重发频率限制(60秒内只能发送一次) rate_limit_key = f"email_verification_rate_limit:{user_id}" if await redis.get(rate_limit_key): - return False, "请等待60秒后再重新发送" + return False, "请等待60秒后再重新发送", {"id": ""} # 设置频率限制 await redis.setex(rate_limit_key, 60, "1") # 生成新的验证码 - success = await EmailVerificationService.send_verification_email( - db, redis, user_id, username, email, ip_address, user_agent + response = await EmailVerificationService.send_verification_email( + db, redis, user_id, username, email, ip_address, user_agent, country_code ) - if success: - return True, "验证码已重新发送" + if response and response.get("id"): + return True, "验证码已重新发送", response else: - return False, "重新发送失败,请稍后再试" + return False, "重新发送失败,请稍后再试", {"id": ""} except Exception as e: logger.error(f"Exception during resending verification code: {e}") - return False, "重新发送过程中发生错误" + return False, "重新发送过程中发生错误", {"id": ""} class LoginSessionService: @@ -573,7 +512,7 @@ class LoginSessionService: await db.exec( select(exists()).where( LoginSession.user_id == user_id, - col(LoginSession.is_verified).is_(False), + col(LoginSession.is_verified).is_(False), # pyright: ignore[reportAttributeAccessIssue] LoginSession.expires_at > utcnow(), LoginSession.token_id == token_id, ) diff --git a/app/templates/email/verification_en.html b/app/templates/email/verification_en.html new file mode 100644 index 0000000..8b9c04c --- /dev/null +++ b/app/templates/email/verification_en.html @@ -0,0 +1,141 @@ + + + + + + Email Verification - {{ server_name }} + + + +
+
+

Email Verification

+

Verify Your Account

+
+ +
+
Hello, {{ username }}!
+ +
+

Thank you for registering with {{ server_name }}. To complete your account verification, please use the following verification code:

+
+ +
+
{{ code }}
+
This code will expire in {{ expiry_minutes }} minutes
+
+ +
+

Security Notice

+
    +
  • Do not share this verification code with anyone
  • +
  • If you did not request this code, please ignore this email
  • +
  • For your account security, do not use the same password on other websites
  • +
  • This code can only be used once
  • +
+
+
+ + +
+ + + diff --git a/app/templates/email/verification_en.txt b/app/templates/email/verification_en.txt new file mode 100644 index 0000000..e522a78 --- /dev/null +++ b/app/templates/email/verification_en.txt @@ -0,0 +1,18 @@ +Hello, {{ username }}! + +Thank you for registering with {{ server_name }}. To complete your account verification, please use the following verification code: + +Verification Code: {{ code }} + +This code will expire in {{ expiry_minutes }} minutes. + +Security Notice: +- Do not share this verification code with anyone +- If you did not request this code, please ignore this email +- For your account security, do not use the same password on other websites +- This code can only be used once + +--- +© {{ year }} {{ server_name }}. All rights reserved. +This email was sent automatically, please do not reply. + diff --git a/app/templates/email/verification_zh.html b/app/templates/email/verification_zh.html new file mode 100644 index 0000000..996373a --- /dev/null +++ b/app/templates/email/verification_zh.html @@ -0,0 +1,141 @@ + + + + + + 邮箱验证 - {{ server_name }} + + + +
+
+

邮箱验证

+

Email Verification

+
+ +
+
你好,{{ username }}!
+ +
+

感谢你注册 {{ server_name }}。为了完成账户验证,请使用以下验证码:

+
+ +
+
{{ code }}
+
⏱️ 验证码将在 {{ expiry_minutes }} 分钟内有效
+
+ +
+

安全提示

+
    +
  • 请不要与任何人分享此验证码
  • +
  • 如果您没有请求此验证码,请忽略这封邮件
  • +
  • 为了账户安全,请勿在其他网站使用相同的密码
  • +
  • 验证码仅能使用一次
  • +
+
+
+ + +
+ + + diff --git a/app/templates/email/verification_zh.txt b/app/templates/email/verification_zh.txt new file mode 100644 index 0000000..651d70d --- /dev/null +++ b/app/templates/email/verification_zh.txt @@ -0,0 +1,18 @@ +你好,{{ username }}! + +感谢你注册 {{ server_name }}。为了完成账户验证,请使用以下验证码: + +验证码:{{ code }} + +验证码将在 {{ expiry_minutes }} 分钟内有效。 + +安全提示: +- 请不要与任何人分享此验证码 +- 如果您没有请求此验证码,请忽略这封邮件 +- 为了账户安全,请勿在其他网站使用相同的密码 +- 验证码仅能使用一次 + +--- +© {{ year }} {{ server_name }}. 保留所有权利。 +此邮件由系统自动发送,请勿回复。 + diff --git a/pyproject.toml b/pyproject.toml index 57a8b41..01750c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "fastapi>=0.104.1", "fastapi-limiter>=0.1.6", "httpx>=0.28.1", + "jinja2>=3.1.0", "loguru>=0.7.3", + "mailersend>=2.0.0", "maxminddb>=2.8.2", "newrelic>=10.1.0", "osupyparser>=1.0.7", diff --git a/uv.lock b/uv.lock index 66d910d..b29b286 100644 --- a/uv.lock +++ b/uv.lock @@ -392,6 +392,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -624,7 +666,9 @@ dependencies = [ { name = "fastapi" }, { name = "fastapi-limiter" }, { name = "httpx" }, + { name = "jinja2" }, { name = "loguru" }, + { name = "mailersend" }, { name = "maxminddb" }, { name = "newrelic" }, { name = "osupyparser" }, @@ -666,7 +710,9 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.104.1" }, { name = "fastapi-limiter", specifier = ">=0.1.6" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "jinja2", specifier = ">=3.1.0" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "mailersend", specifier = ">=2.0.0" }, { name = "maxminddb", specifier = ">=2.8.2" }, { name = "newrelic", specifier = ">=10.1.0" }, { name = "osupyparser", git = "https://github.com/MingxuanGame/osupyparser.git" }, @@ -805,6 +851,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -827,6 +885,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "mailersend" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", extra = ["email"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/0e/3d93829741e2dfd51872874042bfd933596204fbab923e6d93797baecaf5/mailersend-2.0.0.tar.gz", hash = "sha256:9f121909eb55716197c0c64d92760091ea4c4a965eebb3245f6634d8bcce0607", size = 58922, upload-time = "2025-08-01T11:00:18.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/80/8ae2cd3681c953c940888e2c9dff13da9a8c076f0d6296b2eee90ee958eb/mailersend-2.0.0-py3-none-any.whl", hash = "sha256:a5280db088c7af11dacf6eab68da4c7d597c30e8fa6a54b20a739aa7df2acc4b", size = 101926, upload-time = "2025-08-01T11:00:17.67Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1440,6 +1511,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rosu-pp-py" version = "3.1.0"