feat: 添加邮件功能第四阶段 - 任务完成通知

1. 添加任务完成邮件模板 (templates/email/task_complete.html)
2. 在email_service.py中添加:
   - task_notify_enabled 字段支持
   - send_task_complete_email() 函数,支持附件大小限制和分批发送
   - send_task_complete_email_async() 异步发送函数
   - MAX_ATTACHMENT_SIZE 常量 (10MB)
3. 更新app.py:
   - 邮件设置API支持task_notify_enabled字段
   - 截图回调中集成任务完成邮件发送
4. 更新admin.html:
   - 添加"启用任务完成通知"开关
   - 更新loadEmailSettings/updateEmailSettings函数

附件超过10MB时会自动分两封邮件发送(通知+截图),作为容错机制

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 22:09:59 +08:00
parent 93375b612f
commit 0ccddd8c63
4 changed files with 373 additions and 6 deletions

31
app.py
View File

@@ -2487,11 +2487,11 @@ def take_screenshot_for_account(user_id, account_id, browse_type="应读", sourc
# 重置账号状态
account.is_running = False
account.status = "未开始"
# 先清除任务状态这样to_dict()不会包含detail_status
if account_id in task_status:
del task_status[account_id]
# 然后发送更新
socketio.emit('account_update', account.to_dict(), room=f'user_{user_id}')
@@ -2517,6 +2517,29 @@ def take_screenshot_for_account(user_id, account_id, browse_type="应读", sourc
source=source
)
# 发送任务完成邮件通知
try:
user_info = database.get_user_by_id(user_id)
if user_info and user_info.get('email'):
screenshot_path = None
if result and result.get('success') and result.get('filename'):
screenshot_path = os.path.join(SCREENSHOTS_DIR, result['filename'])
account_name = account.remark if account.remark else account.username
email_service.send_task_complete_email_async(
user_id=user_id,
email=user_info['email'],
username=user_info['username'],
account_name=account_name,
browse_type=browse_type,
total_items=browse_result.get('total_items', 0),
total_attachments=browse_result.get('total_attachments', 0),
screenshot_path=screenshot_path,
log_callback=lambda msg: log_to_client(msg, user_id, account_id)
)
except Exception as email_error:
logger.warning(f"发送任务完成邮件失败: {email_error}")
except Exception as e:
logger.error(f"截图回调出错: {e}")
@@ -3716,12 +3739,14 @@ def update_email_settings_api():
failover_enabled = data.get('failover_enabled', True)
register_verify_enabled = data.get('register_verify_enabled')
base_url = data.get('base_url')
task_notify_enabled = data.get('task_notify_enabled')
email_service.update_email_settings(
enabled=enabled,
failover_enabled=failover_enabled,
register_verify_enabled=register_verify_enabled,
base_url=base_url
base_url=base_url,
task_notify_enabled=task_notify_enabled
)
return jsonify({'success': True})
except Exception as e:

View File

@@ -55,6 +55,11 @@ RATE_LIMIT_BIND = 60 # 绑定邮件: 1分钟
QUEUE_WORKERS = int(os.environ.get('EMAIL_QUEUE_WORKERS', '2'))
QUEUE_MAX_SIZE = int(os.environ.get('EMAIL_QUEUE_MAX_SIZE', '100'))
# 附件大小限制(字节)
# 大多数邮件服务商限制单封邮件附件大小为10-25MB
# 为安全起见设置为10MB超过则分批发送
MAX_ATTACHMENT_SIZE = int(os.environ.get('EMAIL_MAX_ATTACHMENT_SIZE', str(10 * 1024 * 1024))) # 10MB
# ============ 数据库操作 ============
@@ -108,6 +113,7 @@ def init_email_tables():
enabled INTEGER DEFAULT 0,
failover_enabled INTEGER DEFAULT 1,
register_verify_enabled INTEGER DEFAULT 0,
task_notify_enabled INTEGER DEFAULT 0,
base_url TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -201,9 +207,15 @@ def get_email_settings() -> Dict[str, Any]:
except:
cursor.execute("ALTER TABLE email_settings ADD COLUMN base_url TEXT DEFAULT ''")
conn.commit()
try:
cursor.execute("SELECT task_notify_enabled FROM email_settings LIMIT 1")
except:
cursor.execute("ALTER TABLE email_settings ADD COLUMN task_notify_enabled INTEGER DEFAULT 0")
conn.commit()
cursor.execute("""
SELECT enabled, failover_enabled, register_verify_enabled, base_url, updated_at
SELECT enabled, failover_enabled, register_verify_enabled, base_url,
task_notify_enabled, updated_at
FROM email_settings WHERE id = 1
""")
row = cursor.fetchone()
@@ -213,13 +225,15 @@ def get_email_settings() -> Dict[str, Any]:
'failover_enabled': bool(row[1]),
'register_verify_enabled': bool(row[2]) if row[2] is not None else False,
'base_url': row[3] or '',
'updated_at': row[4]
'task_notify_enabled': bool(row[4]) if row[4] is not None else False,
'updated_at': row[5]
}
return {
'enabled': False,
'failover_enabled': True,
'register_verify_enabled': False,
'base_url': '',
'task_notify_enabled': False,
'updated_at': None
}
@@ -228,7 +242,8 @@ def update_email_settings(
enabled: bool,
failover_enabled: bool,
register_verify_enabled: bool = None,
base_url: str = None
base_url: str = None,
task_notify_enabled: bool = None
) -> bool:
"""更新全局邮件设置"""
with db_pool.get_db() as conn:
@@ -246,6 +261,10 @@ def update_email_settings(
updates.append('base_url = ?')
params.append(base_url)
if task_notify_enabled is not None:
updates.append('task_notify_enabled = ?')
params.append(int(task_notify_enabled))
cursor.execute(f"""
UPDATE email_settings
SET {', '.join(updates)}
@@ -1567,6 +1586,229 @@ def create_zip_attachment(files: List[Dict[str, Any]], zip_filename: str = 'scre
}
# ============ 任务完成通知邮件 ============
def send_task_complete_email(
user_id: int,
email: str,
username: str,
account_name: str,
browse_type: str,
total_items: int,
total_attachments: int,
screenshot_path: str = None,
log_callback: Callable = None
) -> Dict[str, Any]:
"""
发送任务完成通知邮件(支持附件大小限制,超过则分批发送)
Args:
user_id: 用户ID
email: 收件人邮箱
username: 用户名
account_name: 账号名称
browse_type: 浏览类型
total_items: 浏览条目数
total_attachments: 附件数量
screenshot_path: 截图文件路径
log_callback: 日志回调函数
Returns:
{'success': bool, 'error': str, 'emails_sent': int}
"""
# 检查邮件功能是否启用
settings = get_email_settings()
if not settings.get('enabled', False):
return {'success': False, 'error': '邮件功能未启用', 'emails_sent': 0}
if not settings.get('task_notify_enabled', False):
return {'success': False, 'error': '任务通知功能未启用', 'emails_sent': 0}
if not email:
return {'success': False, 'error': '用户未设置邮箱', 'emails_sent': 0}
# 获取完成时间
complete_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 读取截图文件
screenshot_data = None
screenshot_filename = None
if screenshot_path and os.path.exists(screenshot_path):
try:
with open(screenshot_path, 'rb') as f:
screenshot_data = f.read()
screenshot_filename = os.path.basename(screenshot_path)
except Exception as e:
if log_callback:
log_callback(f"[邮件] 读取截图文件失败: {e}")
# 读取邮件模板
template_path = os.path.join(os.path.dirname(__file__), 'templates', 'email', 'task_complete.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>账号:{{ account_name }}</p>
<p>浏览类型:{{ browse_type }}</p>
<p>浏览条目:{{ total_items }} 条</p>
<p>附件数量:{{ total_attachments }} 个</p>
<p>完成时间:{{ complete_time }}</p>
</body>
</html>
"""
# 准备发送
emails_sent = 0
last_error = ''
# 检查附件大小,决定是否需要分批发送
if screenshot_data and len(screenshot_data) > MAX_ATTACHMENT_SIZE:
# 附件超过限制,不压缩直接发送(大图片压缩效果不大)
# 这种情况很少见,但作为容错处理
batch_info = '(由于文件较大,截图将单独发送)'
# 先发送不带附件的通知邮件
html_body = html_template.replace('{{ username }}', username)
html_body = html_body.replace('{{ account_name }}', account_name)
html_body = html_body.replace('{{ browse_type }}', browse_type)
html_body = html_body.replace('{{ total_items }}', str(total_items))
html_body = html_body.replace('{{ total_attachments }}', str(total_attachments))
html_body = html_body.replace('{{ complete_time }}', complete_time)
html_body = html_body.replace('{{ batch_info }}', batch_info)
text_body = f"""
您好,{username}
您的浏览任务已完成。
账号:{account_name}
浏览类型:{browse_type}
浏览条目:{total_items}
附件数量:{total_attachments}
完成时间:{complete_time}
截图将在下一封邮件中发送。
"""
result = send_email(
to_email=email,
subject=f'【知识管理平台】任务完成 - {account_name}',
body=text_body,
html_body=html_body,
email_type=EMAIL_TYPE_TASK_COMPLETE,
user_id=user_id,
log_callback=log_callback
)
if result['success']:
emails_sent += 1
if log_callback:
log_callback(f"[邮件] 任务通知已发送")
else:
last_error = result['error']
# 单独发送截图附件
attachment = [{'filename': screenshot_filename, 'data': screenshot_data}]
result2 = send_email(
to_email=email,
subject=f'【知识管理平台】任务截图 - {account_name}',
body=f'这是 {account_name} 的任务截图。',
attachments=attachment,
email_type=EMAIL_TYPE_TASK_COMPLETE,
user_id=user_id,
log_callback=log_callback
)
if result2['success']:
emails_sent += 1
if log_callback:
log_callback(f"[邮件] 截图附件已发送")
else:
last_error = result2['error']
else:
# 正常情况:附件大小在限制内,一次性发送
batch_info = ''
attachments = None
if screenshot_data:
attachments = [{'filename': screenshot_filename, 'data': screenshot_data}]
html_body = html_template.replace('{{ username }}', username)
html_body = html_body.replace('{{ account_name }}', account_name)
html_body = html_body.replace('{{ browse_type }}', browse_type)
html_body = html_body.replace('{{ total_items }}', str(total_items))
html_body = html_body.replace('{{ total_attachments }}', str(total_attachments))
html_body = html_body.replace('{{ complete_time }}', complete_time)
html_body = html_body.replace('{{ batch_info }}', batch_info)
text_body = f"""
您好,{username}
您的浏览任务已完成。
账号:{account_name}
浏览类型:{browse_type}
浏览条目:{total_items}
附件数量:{total_attachments}
完成时间:{complete_time}
{'截图已附在邮件中。' if screenshot_data else ''}
"""
result = send_email(
to_email=email,
subject=f'【知识管理平台】任务完成 - {account_name}',
body=text_body,
html_body=html_body,
attachments=attachments,
email_type=EMAIL_TYPE_TASK_COMPLETE,
user_id=user_id,
log_callback=log_callback
)
if result['success']:
emails_sent += 1
if log_callback:
log_callback(f"[邮件] 任务通知已发送")
else:
last_error = result['error']
return {
'success': emails_sent > 0,
'error': last_error if emails_sent == 0 else '',
'emails_sent': emails_sent
}
def send_task_complete_email_async(
user_id: int,
email: str,
username: str,
account_name: str,
browse_type: str,
total_items: int,
total_attachments: int,
screenshot_path: str = None,
log_callback: Callable = None
):
"""异步发送任务完成通知邮件"""
import threading
thread = threading.Thread(
target=send_task_complete_email,
args=(user_id, email, username, account_name, browse_type,
total_items, total_attachments, screenshot_path, log_callback),
daemon=True
)
thread.start()
# ============ 初始化 ============
def init_email_service():

View File

@@ -1233,6 +1233,15 @@
开启后,新用户注册需通过邮箱验证才能激活账号(优先级高于自动审核)
</div>
</div>
<div class="form-group" style="margin-bottom: 10px;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="taskNotifyEnabled" onchange="updateEmailSettings()" style="width: auto; max-width: none;">
启用任务完成通知
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px;">
开启后,定时任务完成时将发送邮件通知给用户(用户需已设置邮箱)
</div>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label style="display: block; margin-bottom: 5px;">网站基础URL</label>
<input type="text" id="baseUrl" placeholder="例如: https://example.com" style="width: 100%;" onblur="updateEmailSettings()">
@@ -2797,6 +2806,7 @@
document.getElementById('emailEnabled').checked = data.enabled;
document.getElementById('failoverEnabled').checked = data.failover_enabled;
document.getElementById('registerVerifyEnabled').checked = data.register_verify_enabled || false;
document.getElementById('taskNotifyEnabled').checked = data.task_notify_enabled || false;
document.getElementById('baseUrl').value = data.base_url || '';
}
} catch (error) {
@@ -2814,6 +2824,7 @@
enabled: document.getElementById('emailEnabled').checked,
failover_enabled: document.getElementById('failoverEnabled').checked,
register_verify_enabled: document.getElementById('registerVerifyEnabled').checked,
task_notify_enabled: document.getElementById('taskNotifyEnabled').checked,
base_url: document.getElementById('baseUrl').value.trim()
})
});

View File

@@ -0,0 +1,89 @@
<!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, #27ae60 0%, #2ecc71 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" style="background: #f8f9fa; border-radius: 8px; margin-bottom: 25px;">
<tr>
<td style="padding: 20px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">账号:</td>
<td style="padding: 8px 0; color: #333; font-size: 14px; font-weight: bold;">{{ account_name }}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">浏览类型:</td>
<td style="padding: 8px 0; color: #333; font-size: 14px;">{{ browse_type }}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">浏览条目:</td>
<td style="padding: 8px 0; color: #27ae60; font-size: 14px; font-weight: bold;">{{ total_items }} 条</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">附件数量:</td>
<td style="padding: 8px 0; color: #333; font-size: 14px;">{{ total_attachments }} 个</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">完成时间:</td>
<td style="padding: 8px 0; color: #333; font-size: 14px;">{{ complete_time }}</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- 截图信息 -->
<div style="background: #e8f5e9; border-left: 4px solid #27ae60; padding: 15px; margin: 25px 0; border-radius: 0 5px 5px 0;">
<p style="color: #2e7d32; font-size: 13px; margin: 0;">
<strong>截图已附在邮件中</strong>{{ batch_info }}
</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;">
&copy; 知识管理平台
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>