✉️ 添加完整的邮件系统功能

后端新增功能:
- 集成 nodemailer 支持 SMTP 邮件发送
- 新增邮箱验证系统(VerificationDB)
- 新增密码重置令牌系统(PasswordResetTokenDB)
- 实现邮件发送限流(30分钟3次,全天10次)
- 添加 SMTP 配置管理接口
- 支持邮箱激活和密码重置邮件发送

前端新增功能:
- 注册时邮箱必填并需验证
- 邮箱验证激活流程
- 重发激活邮件功能
- 基于邮箱的密码重置流程(替代管理员审核)
- 管理后台 SMTP 配置界面
- SMTP 测试邮件发送功能

安全改进:
- 邮件发送防刷限流保护
- 验证令牌随机生成(48字节)
- 重置链接有效期限制
- 支持 SSL/TLS 加密传输

支持的邮箱服务:QQ邮箱、163邮箱、企业邮箱等主流SMTP服务

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 14:28:35 +08:00
parent fafd897588
commit 6958655d6e
5 changed files with 684 additions and 53 deletions

View File

@@ -682,6 +682,9 @@
</div>
<small style="color: #666; font-size: 12px;">点击图片刷新验证码</small>
</div>
<div v-if="showResendVerify" class="alert alert-info" style="margin-bottom: 10px;">
邮箱未验证?<a style="color:#667eea; cursor: pointer;" @click="resendVerification">点击重发激活邮件</a>
</div>
<div style="text-align: right; margin-bottom: 15px;">
<a @click="showForgotPasswordModal = true" style="color: #667eea; cursor: pointer; font-size: 14px; text-decoration: none;">
忘记密码?
@@ -697,8 +700,8 @@
<input type="text" class="form-input" v-model="registerForm.username" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<label class="form-label">邮箱 (可选)</label>
<input type="email" class="form-input" v-model="registerForm.email">
<label class="form-label">邮箱 (必填,用于激活)</label>
<input type="email" class="form-input" v-model="registerForm.email" required>
</div>
<div class="form-group">
<label class="form-label">密码 (至少6字符)</label>
@@ -1504,6 +1507,61 @@
</div>
</div>
<!-- 系统设置 -->
<div class="card" style="margin-bottom: 30px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-sliders-h"></i> 系统设置
</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px;">
<div class="form-group">
<label class="form-label">最大上传大小 (MB)</label>
<input type="number" class="form-input" v-model.number="systemSettings.maxUploadSizeMB" min="1">
</div>
</div>
<hr style="margin: 20px 0;">
<h4 style="margin-bottom: 12px;">SMTP 邮件配置(用于注册激活和找回密码)</h4>
<div class="alert alert-info" style="margin-bottom: 15px;">
支持 QQ/163/企业邮箱等。QQ 邮箱示例:主机 <code>smtp.qq.com</code>,端口 <code>465</code>,勾选 SSL用户名=邮箱地址,密码=授权码。
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px;">
<div class="form-group">
<label class="form-label">SMTP 主机</label>
<input type="text" class="form-input" v-model="systemSettings.smtp.host" placeholder="如 smtp.qq.com">
</div>
<div class="form-group">
<label class="form-label">端口</label>
<input type="number" class="form-input" v-model.number="systemSettings.smtp.port" placeholder="465/587">
</div>
<div class="form-group">
<label class="form-label">SSL/TLS</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="smtp-secure" v-model="systemSettings.smtp.secure">
<label for="smtp-secure" style="margin: 0;">使用 SSL465 通常需要)</label>
</div>
</div>
<div class="form-group">
<label class="form-label">用户名(邮箱)</label>
<input type="text" class="form-input" v-model="systemSettings.smtp.user" placeholder="your@qq.com">
</div>
<div class="form-group">
<label class="form-label">发件人 From可选</label>
<input type="text" class="form-input" v-model="systemSettings.smtp.from" placeholder="显示名称 <your@qq.com>">
</div>
<div class="form-group">
<label class="form-label">密码/授权码</label>
<input type="password" class="form-input" v-model="systemSettings.smtp.password" :placeholder="systemSettings.smtp.has_password ? '已配置,留空则不修改' : '请输入授权码'">
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px;">
<button class="btn btn-primary" @click="updateSystemSettings">
<i class="fas fa-save"></i> 保存设置
</button>
<button class="btn btn-secondary" @click="testSmtp">
<i class="fas fa-envelope"></i> 发送测试邮件
</button>
</div>
</div>
<h3 style="margin-bottom: 20px;">用户管理</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 900px;">
@@ -1686,23 +1744,41 @@
<!-- 忘记密码模态框 -->
<div v-if="showForgotPasswordModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showForgotPasswordModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">忘记密码 - 提交重置请求</h3>
<h3 style="margin-bottom: 20px;">忘记密码 - 邮箱重置</h3>
<p style="color: #666; margin-bottom: 15px; font-size: 14px;">
请输入您的用户名和新密码,提交后需要等待管理员审核批准
请输入注册邮箱,我们会发送重置链接到您的邮箱
</p>
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="forgotPasswordForm.username" placeholder="请输入用户名" required>
</div>
<div class="form-group">
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="forgotPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
<label class="form-label">邮箱</label>
<input type="email" class="form-input" v-model="forgotPasswordForm.email" placeholder="请输入注册邮箱" required>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="requestPasswordReset" style="flex: 1;">
<i class="fas fa-paper-plane"></i> 提交请求
<i class="fas fa-paper-plane"></i> 发送重置邮件
</button>
<button class="btn btn-secondary" @click="showForgotPasswordModal = false; forgotPasswordForm = {username: '', new_password: ''}" style="flex: 1;">
<button class="btn btn-secondary" @click="showForgotPasswordModal = false; forgotPasswordForm = {email: ''}" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>
</div>
</div>
<!-- 邮件重置密码模态框 -->
<div v-if="showResetPasswordModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showResetPasswordModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">设置新密码</h3>
<p style="color: #666; margin-bottom: 15px; font-size: 14px;">
重置链接已验证,请输入新密码
</p>
<div class="form-group">
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="resetPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="submitResetPassword" style="flex: 1;">
<i class="fas fa-unlock"></i> 重置密码
</button>
<button class="btn btn-secondary" @click="showResetPasswordModal = false; resetPasswordForm = {token: '', new_password: ''}" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>

View File

@@ -127,13 +127,28 @@ createApp({
// 忘记密码
showForgotPasswordModal: false,
forgotPasswordForm: {
username: '',
email: ''
},
showResetPasswordModal: false,
resetPasswordForm: {
token: '',
new_password: ''
},
showResendVerify: false,
resendVerifyEmail: '',
// 系统设置
systemSettings: {
maxUploadSizeMB: 100
maxUploadSizeMB: 100,
smtp: {
host: '',
port: 465,
secure: true,
user: '',
from: '',
password: '',
has_password: false
}
},
// Toast通知
@@ -325,6 +340,8 @@ handleDragLeave(e) {
this.token = response.data.token;
this.user = response.data.user;
this.isLoggedIn = true;
this.showResendVerify = false;
this.resendVerifyEmail = '';
// 登录成功后隐藏验证码并清空验证码输入
this.showCaptcha = false;
@@ -393,6 +410,15 @@ handleDragLeave(e) {
this.showCaptcha = true;
this.refreshCaptcha();
}
// 邮箱未验证提示
if (error.response?.data?.needVerify) {
this.showResendVerify = true;
this.resendVerifyEmail = error.response?.data?.email || this.loginForm.username || '';
} else {
this.showResendVerify = false;
this.resendVerifyEmail = '';
}
}
},
@@ -401,6 +427,44 @@ handleDragLeave(e) {
this.captchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`;
},
async resendVerification() {
if (!this.resendVerifyEmail) {
this.showToast('error', '错误', '请输入邮箱或用户名后再重试');
return;
}
try {
const payload = {};
if (this.resendVerifyEmail.includes('@')) {
payload.email = this.resendVerifyEmail;
} else {
payload.username = this.resendVerifyEmail;
}
const response = await axios.post(`${this.apiBase}/api/resend-verification`, payload);
if (response.data.success) {
this.showToast('success', '成功', '验证邮件已发送,请查收');
}
} catch (error) {
console.error('重发验证邮件失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '发送失败');
}
},
async handleVerifyToken(token) {
try {
const response = await axios.get(`${this.apiBase}/api/verify-email`, { params: { token } });
if (response.data.success) {
this.showToast('success', '成功', '邮箱验证成功,请登录');
// 清理URL
const url = new URL(window.location.href);
url.searchParams.delete('verifyToken');
window.history.replaceState({}, document.title, url.toString());
}
} catch (error) {
console.error('邮箱验证失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '验证失败');
}
},
async handleRegister() {
this.errorMessage = '';
this.successMessage = '';
@@ -409,7 +473,7 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/register`, this.registerForm);
if (response.data.success) {
this.successMessage = '注册成功!请登录';
this.successMessage = '注册成功!请查收邮箱完成验证后再登录';
this.isLogin = true;
// 清空表单
@@ -696,6 +760,8 @@ handleDragLeave(e) {
this.token = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
this.showResendVerify = false;
this.resendVerifyEmail = '';
// 停止定期检查
this.stopProfileSync();
@@ -1579,25 +1645,21 @@ handleDragLeave(e) {
// ===== 忘记密码功能 =====
async requestPasswordReset() {
if (!this.forgotPasswordForm.username) {
this.showToast('error', '错误', '请输入用户名');
return;
}
if (!this.forgotPasswordForm.new_password || this.forgotPasswordForm.new_password.length < 6) {
this.showToast('error', '错误', '新密码至少6个字符');
if (!this.forgotPasswordForm.email) {
this.showToast('error', '错误', '请输入注册邮箱');
return;
}
try {
const response = await axios.post(
`${this.apiBase}/api/password-reset/request`,
`${this.apiBase}/api/password/forgot`,
this.forgotPasswordForm
);
if (response.data.success) {
this.showToast('success', '成功', '密码重置请求已提交,请等待管理员审核');
this.showToast('success', '成功', '如果邮箱存在,将收到重置邮件');
this.showForgotPasswordModal = false;
this.forgotPasswordForm = { username: '', new_password: '' };
this.forgotPasswordForm = { email: '' };
}
} catch (error) {
console.error('提交密码重置请求失败:', error);
@@ -1605,6 +1667,28 @@ handleDragLeave(e) {
}
},
async submitResetPassword() {
if (!this.resetPasswordForm.token || !this.resetPasswordForm.new_password || this.resetPasswordForm.new_password.length < 6) {
this.showToast('error', '错误', '请输入有效的重置链接和新密码至少6位');
return;
}
try {
const response = await axios.post(`${this.apiBase}/api/password/reset`, this.resetPasswordForm);
if (response.data.success) {
this.showToast('success', '成功', '密码已重置,请登录');
this.showResetPasswordModal = false;
this.resetPasswordForm = { token: '', new_password: '' };
// 清理URL中的token
const url = new URL(window.location.href);
url.searchParams.delete('resetToken');
window.history.replaceState({}, document.title, url.toString());
}
} catch (error) {
console.error('密码重置失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '重置失败');
}
},
// ===== 管理员:密码重置审核 =====
async loadPasswordResetRequests() {
@@ -1978,6 +2062,15 @@ handleDragLeave(e) {
if (response.data.success) {
const settings = response.data.settings;
this.systemSettings.maxUploadSizeMB = Math.round(settings.max_upload_size / (1024 * 1024));
if (settings.smtp) {
this.systemSettings.smtp.host = settings.smtp.host || '';
this.systemSettings.smtp.port = settings.smtp.port || 465;
this.systemSettings.smtp.secure = !!settings.smtp.secure;
this.systemSettings.smtp.user = settings.smtp.user || '';
this.systemSettings.smtp.from = settings.smtp.from || settings.smtp.user || '';
this.systemSettings.smtp.has_password = !!settings.smtp.has_password;
this.systemSettings.smtp.password = '';
}
}
} catch (error) {
console.error('加载系统设置失败:', error);
@@ -2000,25 +2093,54 @@ handleDragLeave(e) {
}
},
async updateSystemSettings() {
try {
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
async updateSystemSettings() {
try {
const maxUploadSize = parseInt(this.systemSettings.maxUploadSizeMB) * 1024 * 1024;
const payload = {
max_upload_size: maxUploadSize,
smtp: {
host: this.systemSettings.smtp.host,
port: this.systemSettings.smtp.port,
secure: this.systemSettings.smtp.secure,
user: this.systemSettings.smtp.user,
from: this.systemSettings.smtp.from || this.systemSettings.smtp.user
}
};
if (this.systemSettings.smtp.password) {
payload.smtp.password = this.systemSettings.smtp.password;
}
const response = await axios.post(
`${this.apiBase}/api/admin/settings`,
payload,
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
this.showToast('success', '成功', '系统设置已更新');
this.systemSettings.smtp.password = '';
}
} catch (error) {
console.error('更新系统设置失败:', error);
this.showToast('error', '错误', '更新系统设置失败');
}
}
,
async testSmtp() {
try {
const response = await axios.post(
`${this.apiBase}/api/admin/settings`,
{ max_upload_size: maxUploadSize },
`${this.apiBase}/api/admin/settings/test-smtp`,
{ to: this.systemSettings.smtp.user },
{ headers: { Authorization: `Bearer ${this.token}` } }
);
if (response.data.success) {
this.showToast('success', '成功', '系统设置已更新');
}
this.showToast('success', '成功', response.data.message || '测试邮件已发送');
} catch (error) {
console.error('更新系统设置失败:', error);
this.showToast('error', '错误', '更新系统设置失败');
console.error('测试SMTP失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '测试失败');
}
}
,
},
// ===== 上传工具管理 =====
@@ -2137,6 +2259,18 @@ handleDragLeave(e) {
// 初始化调试模式状态
this.debugMode = localStorage.getItem('debugMode') === 'true';
// 处理URL中的验证/重置token
const url = new URL(window.location.href);
const verifyToken = url.searchParams.get('verifyToken');
const resetToken = url.searchParams.get('resetToken');
if (verifyToken) {
this.handleVerifyToken(verifyToken);
}
if (resetToken) {
this.resetPasswordForm.token = resetToken;
this.showResetPasswordModal = true;
}
// 阻止全局拖拽默认行为(防止拖到区域外打开新页面)
window.addEventListener("dragover", (e) => {
e.preventDefault();