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.
This commit is contained in:
咕谷酱
2025-10-12 00:36:47 +08:00
parent a32976857f
commit 6731373ded
15 changed files with 886 additions and 163 deletions

View File

@@ -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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
.container {{
max-width: 600px;
margin: 0 auto;
font-family: Arial, sans-serif;
line-height: 1.6;
}}
.header {{
background: #ED8EA6;
color: white;
padding: 20px;
text-align: center;
border-radius: 10px 10px 0 0;
}}
.content {{
background: #f9f9f9;
padding: 30px;
border: 1px solid #ddd;
}}
.code {{
background: #fff;
border: 2px solid #ED8EA6;
border-radius: 8px;
padding: 15px;
text-align: center;
font-size: 24px;
font-weight: bold;
letter-spacing: 3px;
margin: 20px 0;
color: #333;
}}
.footer {{
background: #333;
color: #fff;
padding: 15px;
text-align: center;
border-radius: 0 0 10px 10px;
font-size: 12px;
}}
.warning {{
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 5px;
padding: 10px;
margin: 15px 0;
color: #856404;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>osu! 邮箱验证</h1>
<p>Email Verification</p>
</div>
from app.service.email_template_service import get_email_template_service
<div class="content">
<h2>你好 {username}</h2>
<p>请使用以下验证码验证您的账户:</p>
<div class="code">{code}</div>
<p>验证码将在 <strong>10 分钟内有效</strong>。</p>
<div class="warning">
<p><strong>重要提示:</strong></p>
<ul>
<li>请不要与任何人分享此验证码</li>
<li>如果您没有请求验证码,请忽略此邮件</li>
<li>为了账户安全,请勿在其他网站使用相同的密码</li>
</ul>
</div>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;">
<h3>Hello {username}!</h3>
<p>Please use the following verification code to verify your account:</p>
<p>This verification code will be valid for <strong>10 minutes</strong>.</p>
<p><strong>Important:</strong> Do not share this verification code with anyone. If you did not request this code, please ignore this email.</p>
</div>
<div class="footer">
<p>© 2025 g0v0! Private Server. 此邮件由系统自动发送,请勿回复。</p>
<p>This email was sent automatically, please do not reply.</p>
</div>
</div>
</body>
</html>
""" # 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,
)