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

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():