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"""
-
-
-
-
-
-
-
-
-
+ 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 }}
+
+
+
+
+
+
+
+
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 }}
+
+
+
+
+
+
+
+
你好,{{ 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"