c72be74727
- Implement EmailService class with configurable SMTP - Support verification/password-reset/invitation email templates - Support HTML and plain text fallback - Support CC/BCC recipients - Add 8 comprehensive unit tests with Mock SMTP (all passed) Phase 4 Task 4/68 completed
330 lines
11 KiB
Python
330 lines
11 KiB
Python
"""
|
||
邮件服务
|
||
支持 SMTP 发送邮件(验证/重置密码/邀请等)
|
||
"""
|
||
import smtplib
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from typing import Optional, List
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass
|
||
class EmailConfig:
|
||
"""邮件配置"""
|
||
smtp_host: str = "smtp.gmail.com"
|
||
smtp_port: int = 587
|
||
smtp_user: str = ""
|
||
smtp_password: str = ""
|
||
from_email: str = ""
|
||
from_name: str = "小虾 SaaS"
|
||
use_tls: bool = True
|
||
|
||
|
||
class EmailService:
|
||
"""邮件服务类"""
|
||
|
||
def __init__(self, config: Optional[EmailConfig] = None):
|
||
"""
|
||
初始化邮件服务
|
||
|
||
Args:
|
||
config: 邮件配置
|
||
"""
|
||
self.config = config or EmailConfig()
|
||
|
||
def send_email(
|
||
self,
|
||
to_email: str,
|
||
subject: str,
|
||
html_body: str,
|
||
text_body: Optional[str] = None,
|
||
cc: Optional[List[str]] = None,
|
||
bcc: Optional[List[str]] = None,
|
||
) -> tuple[bool, Optional[str]]:
|
||
"""
|
||
发送邮件
|
||
|
||
Args:
|
||
to_email: 收件人邮箱
|
||
subject: 邮件主题
|
||
html_body: HTML 正文
|
||
text_body: 纯文本正文(可选,作为 HTML 的备用)
|
||
cc: 抄送列表
|
||
bcc: 密送列表
|
||
|
||
Returns:
|
||
(是否成功, 错误信息)
|
||
"""
|
||
try:
|
||
# 创建邮件
|
||
msg = MIMEMultipart("alternative")
|
||
msg["Subject"] = subject
|
||
msg["From"] = f"{self.config.from_name} <{self.config.from_email}>"
|
||
msg["To"] = to_email
|
||
|
||
if cc:
|
||
msg["Cc"] = ", ".join(cc)
|
||
|
||
# 添加纯文本正文
|
||
if text_body:
|
||
part1 = MIMEText(text_body, "plain", "utf-8")
|
||
msg.attach(part1)
|
||
|
||
# 添加 HTML 正文
|
||
part2 = MIMEText(html_body, "html", "utf-8")
|
||
msg.attach(part2)
|
||
|
||
# 连接 SMTP 服务器
|
||
with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port) as server:
|
||
if self.config.use_tls:
|
||
server.starttls()
|
||
|
||
# 登录
|
||
if self.config.smtp_user and self.config.smtp_password:
|
||
server.login(self.config.smtp_user, self.config.smtp_password)
|
||
|
||
# 发送
|
||
recipients = [to_email]
|
||
if cc:
|
||
recipients.extend(cc)
|
||
if bcc:
|
||
recipients.extend(bcc)
|
||
|
||
server.sendmail(
|
||
self.config.from_email,
|
||
recipients,
|
||
msg.as_string()
|
||
)
|
||
|
||
return True, None
|
||
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def send_verification_email(
|
||
self,
|
||
to_email: str,
|
||
username: str,
|
||
verification_url: str,
|
||
) -> tuple[bool, Optional[str]]:
|
||
"""
|
||
发送邮箱验证邮件
|
||
|
||
Args:
|
||
to_email: 收件人邮箱
|
||
username: 用户名
|
||
verification_url: 验证链接
|
||
|
||
Returns:
|
||
(是否成功, 错误信息)
|
||
"""
|
||
subject = "验证您的邮箱 - 小虾 SaaS"
|
||
|
||
html_body = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
</head>
|
||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: #2563eb;">欢迎加入小虾 SaaS!</h2>
|
||
<p>你好 <strong>{username}</strong>,</p>
|
||
<p>感谢您注册小虾 SaaS!请点击下面的按钮验证您的邮箱地址:</p>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="{verification_url}"
|
||
style="background-color: #2563eb; color: white; padding: 12px 30px;
|
||
text-decoration: none; border-radius: 5px; display: inline-block;">
|
||
验证邮箱
|
||
</a>
|
||
</div>
|
||
<p style="color: #666; font-size: 14px;">
|
||
如果按钮无法点击,请复制以下链接到浏览器:<br>
|
||
<a href="{verification_url}">{verification_url}</a>
|
||
</p>
|
||
<p style="color: #666; font-size: 14px;">
|
||
此链接将在 24 小时后过期。
|
||
</p>
|
||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||
<p style="color: #999; font-size: 12px;">
|
||
如果您没有注册小虾 SaaS,请忽略此邮件。
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
text_body = f"""
|
||
欢迎加入小虾 SaaS!
|
||
|
||
你好 {username},
|
||
|
||
感谢您注册小虾 SaaS!请访问以下链接验证您的邮箱地址:
|
||
|
||
{verification_url}
|
||
|
||
此链接将在 24 小时后过期。
|
||
|
||
如果您没有注册小虾 SaaS,请忽略此邮件。
|
||
"""
|
||
|
||
return self.send_email(to_email, subject, html_body, text_body)
|
||
|
||
def send_password_reset_email(
|
||
self,
|
||
to_email: str,
|
||
username: str,
|
||
reset_url: str,
|
||
) -> tuple[bool, Optional[str]]:
|
||
"""
|
||
发送密码重置邮件
|
||
|
||
Args:
|
||
to_email: 收件人邮箱
|
||
username: 用户名
|
||
reset_url: 重置链接
|
||
|
||
Returns:
|
||
(是否成功, 错误信息)
|
||
"""
|
||
subject = "重置您的密码 - 小虾 SaaS"
|
||
|
||
html_body = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
</head>
|
||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: #dc2626;">重置密码请求</h2>
|
||
<p>你好 <strong>{username}</strong>,</p>
|
||
<p>我们收到了重置您账号密码的请求。请点击下面的按钮重置密码:</p>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="{reset_url}"
|
||
style="background-color: #dc2626; color: white; padding: 12px 30px;
|
||
text-decoration: none; border-radius: 5px; display: inline-block;">
|
||
重置密码
|
||
</a>
|
||
</div>
|
||
<p style="color: #666; font-size: 14px;">
|
||
如果按钮无法点击,请复制以下链接到浏览器:<br>
|
||
<a href="{reset_url}">{reset_url}</a>
|
||
</p>
|
||
<p style="color: #666; font-size: 14px;">
|
||
此链接将在 1 小时后过期。
|
||
</p>
|
||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||
<p style="color: #999; font-size: 12px;">
|
||
如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
text_body = f"""
|
||
重置密码请求
|
||
|
||
你好 {username},
|
||
|
||
我们收到了重置您账号密码的请求。请访问以下链接重置密码:
|
||
|
||
{reset_url}
|
||
|
||
此链接将在 1 小时后过期。
|
||
|
||
如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。
|
||
"""
|
||
|
||
return self.send_email(to_email, subject, html_body, text_body)
|
||
|
||
def send_workspace_invitation_email(
|
||
self,
|
||
to_email: str,
|
||
inviter_name: str,
|
||
workspace_name: str,
|
||
role: str,
|
||
invitation_url: str,
|
||
) -> tuple[bool, Optional[str]]:
|
||
"""
|
||
发送 Workspace 邀请邮件
|
||
|
||
Args:
|
||
to_email: 收件人邮箱
|
||
inviter_name: 邀请人姓名
|
||
workspace_name: 工作空间名称
|
||
role: 角色(Admin/Member/Viewer)
|
||
invitation_url: 邀请链接
|
||
|
||
Returns:
|
||
(是否成功, 错误信息)
|
||
"""
|
||
subject = f"{inviter_name} 邀请您加入 {workspace_name} - 小虾 SaaS"
|
||
|
||
role_names = {
|
||
"owner": "所有者",
|
||
"admin": "管理员",
|
||
"member": "成员",
|
||
"viewer": "查看者",
|
||
}
|
||
role_display = role_names.get(role.lower(), role)
|
||
|
||
html_body = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
</head>
|
||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: #2563eb;">工作空间邀请</h2>
|
||
<p><strong>{inviter_name}</strong> 邀请您以 <strong>{role_display}</strong> 身份加入工作空间:</p>
|
||
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||
<h3 style="margin: 0 0 10px 0; color: #1f2937;">{workspace_name}</h3>
|
||
<p style="margin: 0; color: #6b7280;">角色:{role_display}</p>
|
||
</div>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="{invitation_url}"
|
||
style="background-color: #2563eb; color: white; padding: 12px 30px;
|
||
text-decoration: none; border-radius: 5px; display: inline-block;">
|
||
接受邀请
|
||
</a>
|
||
</div>
|
||
<p style="color: #666; font-size: 14px;">
|
||
如果按钮无法点击,请复制以下链接到浏览器:<br>
|
||
<a href="{invitation_url}">{invitation_url}</a>
|
||
</p>
|
||
<p style="color: #666; font-size: 14px;">
|
||
此邀请将在 7 天后过期。
|
||
</p>
|
||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||
<p style="color: #999; font-size: 12px;">
|
||
如果您不认识邀请人或不想加入此工作空间,请忽略此邮件。
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
text_body = f"""
|
||
工作空间邀请
|
||
|
||
{inviter_name} 邀请您以 {role_display} 身份加入工作空间:{workspace_name}
|
||
|
||
请访问以下链接接受邀请:
|
||
|
||
{invitation_url}
|
||
|
||
此邀请将在 7 天后过期。
|
||
|
||
如果您不认识邀请人或不想加入此工作空间,请忽略此邮件。
|
||
"""
|
||
|
||
return self.send_email(to_email, subject, html_body, text_body)
|
||
|
||
|
||
# 全局实例(生产环境应该从配置读取)
|
||
email_service = EmailService()
|