13 KiB
13 KiB
邮件服务设计 - ImageForge
目标:提供开箱即用的邮件服务,支持注册验证和密码重置,管理员只需配置邮箱地址和授权码即可使用。
1. 功能范围
1.1 首期必须
- 注册邮箱验证:用户注册后发送验证邮件,点击链接完成激活
- 密码重置:用户申请重置密码,发送重置链接邮件
1.2 后续可选(V1+)
- 订阅到期提醒
- 配额即将用尽提醒
- 支付成功/失败通知
- 安全告警(异地登录、API Key 创建等)
2. 技术方案
2.1 SMTP 直连
使用标准 SMTP 协议,通过 lettre (Rust) 发送邮件,无需第三方 SaaS 依赖。
# Cargo.toml
[dependencies]
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport"] }
2.2 预置服务商模板
管理员只需选择服务商并填写邮箱/授权码,系统自动填充 SMTP 配置:
| 服务商 | SMTP 地址 | 端口 | 加密 | 备注 |
|---|---|---|---|---|
| QQ 邮箱 | smtp.qq.com |
465 | SSL | 需开启 SMTP 服务并获取授权码 |
| 163 邮箱 | smtp.163.com |
465 | SSL | 需开启 SMTP 服务并获取授权码 |
| 阿里企业邮箱 | smtp.qiye.aliyun.com |
465 | SSL | 使用邮箱密码 |
| 腾讯企业邮箱 | smtp.exmail.qq.com |
465 | SSL | 需获取授权码 |
| Gmail | smtp.gmail.com |
587 | STARTTLS | 需开启两步验证并生成应用专用密码 |
| Outlook/365 | smtp.office365.com |
587 | STARTTLS | 使用账号密码 |
| 自定义 | 用户填写 | 用户填写 | 用户选择 | 支持任意 SMTP 服务器 |
3. 配置设计
3.1 环境变量
# .env.example
# 邮件服务配置
MAIL_ENABLED=true
# 开发环境:当 MAIL_ENABLED=false 时,可打开该开关把验证/重置链接打印到日志(便于本地联调)
MAIL_LOG_LINKS_WHEN_DISABLED=true
# 预置服务商(可选:qq / 163 / aliyun_enterprise / tencent_enterprise / gmail / outlook / custom)
MAIL_PROVIDER=qq
# 发件邮箱(必填)
MAIL_FROM=noreply@example.com
# 授权码/密码(必填)
MAIL_PASSWORD=your-smtp-authorization-code
# 发件人名称(可选,默认 "ImageForge")
MAIL_FROM_NAME=ImageForge
# === 以下仅 MAIL_PROVIDER=custom 时需要 ===
# MAIL_SMTP_HOST=smtp.example.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_ENCRYPTION=ssl # ssl / starttls / none
3.2 数据库配置(管理后台可改)
-- system_config 表
INSERT INTO system_config (key, value, description) VALUES
('mail', '{
"enabled": true,
"provider": "qq",
"from": "noreply@example.com",
"from_name": "ImageForge",
"password_encrypted": "...",
"custom_smtp": null
}', '邮件服务配置');
3.3 Rust 配置结构
#[derive(Debug, Clone, Deserialize)]
pub struct MailConfig {
pub enabled: bool,
pub provider: MailProvider,
pub from: String,
pub from_name: String,
pub password: String, // 运行时解密
pub custom_smtp: Option<CustomSmtpConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub enum MailProvider {
QQ,
NetEase163,
AliyunEnterprise,
TencentEnterprise,
Gmail,
Outlook,
Custom,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CustomSmtpConfig {
pub host: String,
pub port: u16,
pub encryption: SmtpEncryption,
}
#[derive(Debug, Clone, Deserialize)]
pub enum SmtpEncryption {
Ssl,
StartTls,
None,
}
impl MailProvider {
pub fn smtp_config(&self) -> (String, u16, SmtpEncryption) {
match self {
Self::QQ => ("smtp.qq.com".into(), 465, SmtpEncryption::Ssl),
Self::NetEase163 => ("smtp.163.com".into(), 465, SmtpEncryption::Ssl),
Self::AliyunEnterprise => ("smtp.qiye.aliyun.com".into(), 465, SmtpEncryption::Ssl),
Self::TencentEnterprise => ("smtp.exmail.qq.com".into(), 465, SmtpEncryption::Ssl),
Self::Gmail => ("smtp.gmail.com".into(), 587, SmtpEncryption::StartTls),
Self::Outlook => ("smtp.office365.com".into(), 587, SmtpEncryption::StartTls),
Self::Custom => panic!("Custom provider requires explicit config"),
}
}
}
4. 邮件模板
4.1 模板存储
建议使用内嵌模板(编译时包含),支持变量替换:
templates/
├── email_verification.html
├── email_verification.txt
├── password_reset.html
└── password_reset.txt
4.2 注册验证邮件
主题:验证您的 ImageForge 账号
HTML 模板 (email_verification.html):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证您的邮箱</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 40px 20px; }
.header { text-align: center; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #4a90d9; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin-bottom: 30px; }
.button { display: inline-block; background: #4a90d9; color: #fff !important; text-decoration: none; padding: 12px 30px; border-radius: 6px; font-weight: 500; }
.button:hover { background: #357abd; }
.footer { text-align: center; font-size: 12px; color: #666; }
.link { word-break: break-all; color: #4a90d9; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">ImageForge</div>
</div>
<div class="content">
<h2>欢迎注册 ImageForge</h2>
<p>您好,{{username}}!</p>
<p>感谢您注册 ImageForge。请点击下方按钮验证您的邮箱地址:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{verification_url}}" class="button">验证邮箱</a>
</p>
<p>或复制以下链接到浏览器打开:</p>
<p class="link">{{verification_url}}</p>
<p style="margin-top: 20px; font-size: 14px; color: #666;">
此链接将在 <strong>24 小时</strong>后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。
</p>
</div>
<div class="footer">
<p>© {{year}} ImageForge. All rights reserved.</p>
<p>此邮件由系统自动发送,请勿直接回复。</p>
</div>
</div>
</body>
</html>
纯文本模板 (email_verification.txt):
欢迎注册 ImageForge
您好,{{username}}!
感谢您注册 ImageForge。请点击以下链接验证您的邮箱地址:
{{verification_url}}
此链接将在 24 小时后失效。如果您没有注册 ImageForge 账号,请忽略此邮件。
---
© {{year}} ImageForge
此邮件由系统自动发送,请勿直接回复。
4.3 密码重置邮件
主题:重置您的 ImageForge 密码
HTML 模板 (password_reset.html):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>重置密码</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 40px 20px; }
.header { text-align: center; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #4a90d9; }
.content { background: #f9fafb; border-radius: 8px; padding: 30px; margin-bottom: 30px; }
.button { display: inline-block; background: #4a90d9; color: #fff !important; text-decoration: none; padding: 12px 30px; border-radius: 6px; font-weight: 500; }
.footer { text-align: center; font-size: 12px; color: #666; }
.link { word-break: break-all; color: #4a90d9; }
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px; margin: 20px 0; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">ImageForge</div>
</div>
<div class="content">
<h2>重置您的密码</h2>
<p>您好,{{username}}!</p>
<p>我们收到了重置您 ImageForge 账号密码的请求。请点击下方按钮设置新密码:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{reset_url}}" class="button">重置密码</a>
</p>
<p>或复制以下链接到浏览器打开:</p>
<p class="link">{{reset_url}}</p>
<div class="warning">
<strong>安全提示:</strong>此链接将在 <strong>1 小时</strong>后失效。如果您没有请求重置密码,请忽略此邮件,您的账号仍然安全。
</div>
</div>
<div class="footer">
<p>© {{year}} ImageForge. All rights reserved.</p>
<p>此邮件由系统自动发送,请勿直接回复。</p>
</div>
</div>
</body>
</html>
5. 数据库设计
5.1 邮箱验证 Token
CREATE TABLE email_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token)
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_email_verifications_token ON email_verifications(token_hash);
CREATE INDEX idx_email_verifications_user_id ON email_verifications(user_id);
CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);
5.2 密码重置 Token
CREATE TABLE password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL, -- SHA256(token)
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_password_resets_token ON password_resets(token_hash);
CREATE INDEX idx_password_resets_user_id ON password_resets(user_id);
CREATE INDEX idx_password_resets_expires_at ON password_resets(expires_at);
5.3 用户表扩展
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
6. API 接口
6.1 发送验证邮件
POST /auth/send-verification
Authorization: Bearer <token>
响应:
{ "success": true, "data": { "message": "验证邮件已发送,请查收" } }
限流:同一用户 1 分钟内最多发送 1 次
6.2 验证邮箱
POST /auth/verify-email
Content-Type: application/json
请求体:
{ "token": "verification-token-from-email" }
响应:
{ "success": true, "data": { "message": "邮箱验证成功" } }
6.3 请求密码重置
POST /auth/forgot-password
Content-Type: application/json
请求体:
{ "email": "user@example.com" }
响应(无论邮箱是否存在都返回成功,防止枚举):
{ "success": true, "data": { "message": "如果该邮箱已注册,您将收到重置邮件" } }
限流:同一 IP 1 分钟内最多请求 3 次
6.4 重置密码
POST /auth/reset-password
Content-Type: application/json
请求体:
{
"token": "reset-token-from-email",
"new_password": "new-secure-password"
}
响应:
{ "success": true, "data": { "message": "密码重置成功,请重新登录" } }
7. 安全考虑
7.1 Token 安全
- Token 使用
crypto-secure random(32 字节 base64) - 数据库只存
SHA256(token),不存明文 - Token 单次有效,使用后立即标记
used_at
7.2 时效控制
- 邮箱验证 Token:24 小时有效
- 密码重置 Token:1 小时有效
7.3 防滥用
- 发送邮件接口严格限流
- 密码重置不泄露"邮箱是否存在"
- 失败尝试记录审计日志
7.4 授权码加密存储
- SMTP 授权码在数据库中加密存储(AES-256-GCM)
- 密钥来自环境变量或密钥管理服务
8. 管理后台配置界面
管理后台提供邮件服务配置页面:
邮件服务配置
├── 启用状态:[开关]
├── 服务商选择:[下拉:QQ邮箱 / 163邮箱 / 阿里企业邮 / 腾讯企业邮 / Gmail / Outlook / 自定义]
├── 发件邮箱:[输入框]
├── 授权码/密码:[密码输入框]
├── 发件人名称:[输入框,默认 ImageForge]
├── (自定义时显示)
│ ├── SMTP 服务器:[输入框]
│ ├── 端口:[输入框]
│ └── 加密方式:[下拉:SSL / STARTTLS / 无]
└── [测试发送] [保存配置]
测试发送:向管理员邮箱发送测试邮件,验证配置是否正确。
9. 常见邮件服务商配置指南
9.1 QQ 邮箱
- 登录 QQ 邮箱 → 设置 → 账户
- 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」
- 开启「SMTP 服务」
- 按提示发送短信获取授权码
- 将授权码填入系统配置
9.2 163 邮箱
- 登录 163 邮箱 → 设置 → POP3/SMTP/IMAP
- 开启「SMTP 服务」
- 设置客户端授权密码
- 将授权密码填入系统配置
9.3 Gmail
- 登录 Google 账号 → 安全性
- 开启「两步验证」
- 生成「应用专用密码」(选择"邮件"+"其他")
- 将应用专用密码填入系统配置
9.4 阿里企业邮箱
- 使用邮箱地址和登录密码即可
- SMTP 服务默认开启