feat: 添加邮件功能第五阶段 - 用户邮箱绑定
1. 添加邮箱绑定验证邮件模板 (templates/email/bind_email.html) 2. 在email_service.py中添加: - send_bind_email_verification() 发送绑定验证邮件 - verify_bind_email_token() 验证绑定Token 3. 在database.py中添加: - update_user_email() 更新用户邮箱 4. 在app.py中添加API: - GET /api/user/email - 获取用户邮箱信息 - POST /api/user/bind-email - 发送绑定验证邮件 - GET /api/verify-bind-email/<token> - 验证绑定Token - POST /api/user/unbind-email - 解绑邮箱 5. 更新templates/index.html: - 将"修改密码"弹窗改为"个人设置" - 添加邮箱绑定/解绑功能UI - 显示邮箱状态(未绑定/待验证/已验证) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
108
app.py
108
app.py
@@ -2841,6 +2841,114 @@ def change_user_password():
|
||||
return jsonify({"error": "密码更新失败"}), 500
|
||||
|
||||
|
||||
@app.route('/api/user/email', methods=['GET'])
|
||||
@login_required
|
||||
def get_user_email():
|
||||
"""获取当前用户的邮箱信息"""
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
|
||||
return jsonify({
|
||||
"email": user.get('email', ''),
|
||||
"email_verified": user.get('email_verified', False)
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/user/bind-email', methods=['POST'])
|
||||
@login_required
|
||||
@require_ip_not_locked
|
||||
def bind_user_email():
|
||||
"""发送邮箱绑定验证邮件"""
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
# 验证邮箱格式
|
||||
if not email or not validate_email(email):
|
||||
return jsonify({"error": "请输入有效的邮箱地址"}), 400
|
||||
|
||||
# 检查邮件功能是否启用
|
||||
settings = email_service.get_email_settings()
|
||||
if not settings.get('enabled', False):
|
||||
return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400
|
||||
|
||||
# 检查邮箱是否已被其他用户使用
|
||||
existing_user = database.get_user_by_email(email)
|
||||
if existing_user and existing_user['id'] != current_user.id:
|
||||
return jsonify({"error": "该邮箱已被其他用户绑定"}), 400
|
||||
|
||||
# 获取当前用户信息
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
|
||||
# 如果已经绑定了相同邮箱且已验证,无需重复绑定
|
||||
if user.get('email') == email and user.get('email_verified'):
|
||||
return jsonify({"error": "该邮箱已绑定并验证"}), 400
|
||||
|
||||
# 发送验证邮件
|
||||
result = email_service.send_bind_email_verification(
|
||||
user_id=current_user.id,
|
||||
email=email,
|
||||
username=user['username']
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "验证邮件已发送,请查收"
|
||||
})
|
||||
else:
|
||||
return jsonify({"error": result['error']}), 500
|
||||
|
||||
|
||||
@app.route('/api/verify-bind-email/<token>')
|
||||
def verify_bind_email(token):
|
||||
"""验证邮箱绑定Token"""
|
||||
result = email_service.verify_bind_email_token(token)
|
||||
|
||||
if result:
|
||||
user_id = result['user_id']
|
||||
email = result['email']
|
||||
|
||||
# 更新用户邮箱
|
||||
if database.update_user_email(user_id, email, verified=True):
|
||||
# 返回成功页面
|
||||
return render_template('verify_success.html',
|
||||
title='邮箱绑定成功',
|
||||
message=f'邮箱 {email} 已成功绑定到您的账号!',
|
||||
redirect_url='/'
|
||||
)
|
||||
else:
|
||||
return render_template('verify_failed.html',
|
||||
title='绑定失败',
|
||||
message='邮箱绑定失败,请重试'
|
||||
)
|
||||
else:
|
||||
return render_template('verify_failed.html',
|
||||
title='链接无效',
|
||||
message='验证链接已过期或无效,请重新发送验证邮件'
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/user/unbind-email', methods=['POST'])
|
||||
@login_required
|
||||
def unbind_user_email():
|
||||
"""解绑用户邮箱"""
|
||||
user = database.get_user_by_id(current_user.id)
|
||||
if not user:
|
||||
return jsonify({"error": "用户不存在"}), 404
|
||||
|
||||
if not user.get('email'):
|
||||
return jsonify({"error": "当前未绑定邮箱"}), 400
|
||||
|
||||
# 解绑邮箱
|
||||
if database.update_user_email(current_user.id, None, verified=False):
|
||||
return jsonify({"success": True, "message": "邮箱已解绑"})
|
||||
else:
|
||||
return jsonify({"error": "解绑失败"}), 500
|
||||
|
||||
|
||||
@app.route('/api/run_stats', methods=['GET'])
|
||||
@login_required
|
||||
def get_run_stats():
|
||||
|
||||
13
database.py
13
database.py
@@ -830,6 +830,19 @@ def get_user_by_email(email):
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
def update_user_email(user_id, email, verified=False):
|
||||
"""更新用户邮箱"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE users
|
||||
SET email = ?, email_verified = ?
|
||||
WHERE id = ?
|
||||
''', (email, int(verified), user_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""获取所有用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
|
||||
116
email_service.py
116
email_service.py
@@ -1404,6 +1404,122 @@ def confirm_password_reset(token: str) -> Optional[Dict[str, Any]]:
|
||||
return {'user_id': result['user_id'], 'email': result['email']}
|
||||
|
||||
|
||||
# ============ 邮箱绑定验证 ============
|
||||
|
||||
def send_bind_email_verification(
|
||||
user_id: int,
|
||||
email: str,
|
||||
username: str,
|
||||
base_url: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送邮箱绑定验证邮件
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
email: 要绑定的邮箱
|
||||
username: 用户名
|
||||
base_url: 网站基础URL
|
||||
|
||||
Returns:
|
||||
{'success': bool, 'error': str, 'token': str}
|
||||
"""
|
||||
# 检查发送频率限制(绑定邮件限制1分钟)
|
||||
if not check_rate_limit(email, EMAIL_TYPE_BIND):
|
||||
return {
|
||||
'success': False,
|
||||
'error': '发送太频繁,请1分钟后再试',
|
||||
'token': None
|
||||
}
|
||||
|
||||
# 使旧的绑定token失效
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE email_tokens SET used = 1
|
||||
WHERE user_id = ? AND token_type = ? AND used = 0
|
||||
""", (user_id, EMAIL_TYPE_BIND))
|
||||
conn.commit()
|
||||
|
||||
# 生成新的验证Token
|
||||
token = generate_email_token(email, EMAIL_TYPE_BIND, user_id)
|
||||
|
||||
# 获取base_url
|
||||
if not base_url:
|
||||
settings = get_email_settings()
|
||||
base_url = settings.get('base_url', '')
|
||||
|
||||
if not base_url:
|
||||
try:
|
||||
from app_config import Config
|
||||
base_url = Config.BASE_URL
|
||||
except:
|
||||
base_url = 'http://localhost:51233'
|
||||
|
||||
# 生成验证链接
|
||||
verify_url = f"{base_url.rstrip('/')}/api/verify-bind-email/{token}"
|
||||
|
||||
# 读取邮件模板
|
||||
template_path = os.path.join(os.path.dirname(__file__), 'templates', 'email', 'bind_email.html')
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
html_template = f.read()
|
||||
except FileNotFoundError:
|
||||
html_template = """
|
||||
<html>
|
||||
<body>
|
||||
<h1>邮箱绑定验证</h1>
|
||||
<p>您好,{{ username }}!</p>
|
||||
<p>请点击下面的链接完成邮箱绑定:</p>
|
||||
<p><a href="{{ verify_url }}">{{ verify_url }}</a></p>
|
||||
<p>此链接1小时内有效。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# 替换模板变量
|
||||
html_body = html_template.replace('{{ username }}', username)
|
||||
html_body = html_body.replace('{{ verify_url }}', verify_url)
|
||||
|
||||
# 纯文本版本
|
||||
text_body = f"""
|
||||
您好,{username}!
|
||||
|
||||
您正在绑定此邮箱到您的账号。请点击下面的链接完成验证:
|
||||
|
||||
{verify_url}
|
||||
|
||||
此链接1小时内有效。
|
||||
|
||||
如果这不是您的操作,请忽略此邮件。
|
||||
"""
|
||||
|
||||
# 发送邮件
|
||||
result = send_email(
|
||||
to_email=email,
|
||||
subject='【知识管理平台】邮箱绑定验证',
|
||||
body=text_body,
|
||||
html_body=html_body,
|
||||
email_type=EMAIL_TYPE_BIND,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return {'success': True, 'error': '', 'token': token}
|
||||
else:
|
||||
return {'success': False, 'error': result['error'], 'token': None}
|
||||
|
||||
|
||||
def verify_bind_email_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
验证邮箱绑定Token
|
||||
|
||||
Returns:
|
||||
成功返回 {'user_id': int, 'email': str},失败返回 None
|
||||
"""
|
||||
return verify_email_token(token, EMAIL_TYPE_BIND)
|
||||
|
||||
|
||||
# ============ 异步发送队列 ============
|
||||
|
||||
class EmailQueue:
|
||||
|
||||
80
templates/email/bind_email.html
Normal file
80
templates/email/bind_email.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: 'Microsoft YaHei', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 30px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
|
||||
<!-- 头部 -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); padding: 30px; border-radius: 10px 10px 0 0; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">知识管理平台</h1>
|
||||
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0; font-size: 14px;">邮箱绑定验证</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 内容 -->
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333; font-size: 16px; margin: 0 0 20px 0;">
|
||||
您好,<strong>{{ username }}</strong>!
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px; line-height: 1.8; margin: 0 0 25px 0;">
|
||||
您正在绑定此邮箱到您的账号。请点击下方按钮完成验证:
|
||||
</p>
|
||||
|
||||
<!-- 验证按钮 -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<a href="{{ verify_url }}" style="display: inline-block; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); color: #ffffff; text-decoration: none; padding: 15px 40px; border-radius: 30px; font-size: 16px; font-weight: bold; box-shadow: 0 4px 15px rgba(52, 152, 219, 0.4);">
|
||||
验证邮箱
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- 链接备用 -->
|
||||
<div style="background: #f8f9fa; border-radius: 8px; padding: 15px; margin: 25px 0;">
|
||||
<p style="color: #666; font-size: 12px; margin: 0 0 10px 0;">
|
||||
如果按钮无法点击,请复制以下链接到浏览器打开:
|
||||
</p>
|
||||
<p style="color: #3498db; font-size: 12px; word-break: break-all; margin: 0;">
|
||||
{{ verify_url }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 25px 0; border-radius: 0 5px 5px 0;">
|
||||
<p style="color: #856404; font-size: 13px; margin: 0;">
|
||||
<strong>安全提示:</strong>此链接1小时内有效。如果这不是您的操作,请忽略此邮件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #999; font-size: 13px; margin: 20px 0 0 0;">
|
||||
绑定成功后,您将可以通过此邮箱接收任务完成通知。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 底部 -->
|
||||
<tr>
|
||||
<td style="background: #f8f9fa; padding: 20px 30px; border-radius: 0 0 10px 10px; text-align: center;">
|
||||
<p style="color: #999; font-size: 12px; margin: 0;">
|
||||
此邮件由系统自动发送,请勿直接回复。
|
||||
</p>
|
||||
<p style="color: #ccc; font-size: 11px; margin: 10px 0 0 0;">
|
||||
© 知识管理平台
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -742,11 +742,37 @@
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<div class="modal-overlay" id="changePasswordModal">
|
||||
<div class="modal" style="max-width: 400px;">
|
||||
<div class="modal" style="max-width: 450px;">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">修改密码</h3>
|
||||
<h3 class="modal-title">个人设置</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 邮箱绑定 -->
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<span style="font-weight: bold; color: #333;">邮箱绑定</span>
|
||||
<span id="emailStatus" style="font-size: 12px;"></span>
|
||||
</div>
|
||||
<div id="emailBindSection">
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<input type="email" class="form-input" id="bindEmail" placeholder="请输入邮箱地址" style="flex: 1;">
|
||||
<button class="btn btn-primary" id="btnBindEmail" onclick="bindEmail()" style="white-space: nowrap;">绑定</button>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #666; margin-top: 8px;">
|
||||
绑定邮箱后可接收任务完成通知
|
||||
</div>
|
||||
</div>
|
||||
<div id="emailBoundSection" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span id="boundEmail" style="color: #333;"></span>
|
||||
<button class="btn btn-text" onclick="unbindEmail()" style="color: #e74c3c;">解绑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<div style="border-top: 1px solid #eee; padding-top: 15px;">
|
||||
<div style="font-weight: bold; color: #333; margin-bottom: 15px;">修改密码</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">当前密码</label>
|
||||
<input type="password" class="form-input" id="currentPassword" placeholder="请输入当前密码">
|
||||
@@ -759,10 +785,11 @@
|
||||
<label class="form-label">确认新密码</label>
|
||||
<input type="password" class="form-input" id="confirmPassword" placeholder="请再次输入新密码">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="changePassword()" style="width: 100%;">确认修改密码</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-text" onclick="closeModal('changePasswordModal')">取消</button>
|
||||
<button class="btn btn-primary" onclick="changePassword()">确认修改</button>
|
||||
<button class="btn btn-text" onclick="closeModal('changePasswordModal')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2047,6 +2074,111 @@
|
||||
.catch(() => showToast('网络错误', 'error'));
|
||||
}
|
||||
|
||||
// 邮箱绑定相关
|
||||
function loadUserEmail() {
|
||||
fetch('/api/user/email')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const bindSection = document.getElementById('emailBindSection');
|
||||
const boundSection = document.getElementById('emailBoundSection');
|
||||
const statusSpan = document.getElementById('emailStatus');
|
||||
const boundEmail = document.getElementById('boundEmail');
|
||||
const bindInput = document.getElementById('bindEmail');
|
||||
|
||||
if (data.email && data.email_verified) {
|
||||
bindSection.style.display = 'none';
|
||||
boundSection.style.display = 'block';
|
||||
boundEmail.textContent = data.email;
|
||||
statusSpan.innerHTML = '<span style="color: #27ae60;">已验证</span>';
|
||||
} else if (data.email) {
|
||||
bindSection.style.display = 'block';
|
||||
boundSection.style.display = 'none';
|
||||
bindInput.value = data.email;
|
||||
statusSpan.innerHTML = '<span style="color: #f39c12;">待验证</span>';
|
||||
} else {
|
||||
bindSection.style.display = 'block';
|
||||
boundSection.style.display = 'none';
|
||||
bindInput.value = '';
|
||||
statusSpan.innerHTML = '<span style="color: #999;">未绑定</span>';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function bindEmail() {
|
||||
const email = document.getElementById('bindEmail').value.trim();
|
||||
if (!email) {
|
||||
showToast('请输入邮箱地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
showToast('请输入有效的邮箱地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnBindEmail');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
|
||||
fetch('/api/user/bind-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '绑定';
|
||||
if (ok && data.success) {
|
||||
showToast('验证邮件已发送,请查收', 'success');
|
||||
document.getElementById('emailStatus').innerHTML = '<span style="color: #f39c12;">待验证</span>';
|
||||
} else {
|
||||
showToast(data.error || '发送失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '绑定';
|
||||
showToast('网络错误', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function unbindEmail() {
|
||||
if (!confirm('确定要解绑邮箱吗?解绑后将无法接收任务通知邮件。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/user/unbind-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (ok && data.success) {
|
||||
showToast('邮箱已解绑', 'success');
|
||||
loadUserEmail();
|
||||
} else {
|
||||
showToast(data.error || '解绑失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('网络错误', 'error'));
|
||||
}
|
||||
|
||||
// 打开设置弹窗时加载邮箱信息
|
||||
const originalOpenModal = window.openModal;
|
||||
window.openModal = function(modalId) {
|
||||
if (modalId === 'changePasswordModal') {
|
||||
loadUserEmail();
|
||||
}
|
||||
if (originalOpenModal) {
|
||||
originalOpenModal(modalId);
|
||||
} else {
|
||||
document.getElementById(modalId).classList.add('active');
|
||||
}
|
||||
};
|
||||
|
||||
// 点击overlay关闭弹窗
|
||||
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
||||
overlay.addEventListener('click', function(e) {
|
||||
|
||||
Reference in New Issue
Block a user