Files
zsglpt/email_service.py

2341 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
邮件服务模块
功能:
1. 多SMTP配置管理主备切换、故障转移
2. 发送纯文本/HTML邮件
3. 发送带附件邮件支持ZIP压缩
4. 异步发送队列
5. 每日发送限额控制
6. 发送日志记录
"""
import os
import smtplib
import threading
import queue
import time
import zipfile
import secrets
from datetime import datetime, timedelta
import pytz
# 北京时区
BEIJING_TZ = pytz.timezone('Asia/Shanghai')
def get_beijing_today():
"""获取北京时间的今天日期字符串"""
return datetime.now(BEIJING_TZ).strftime('%Y-%m-%d')
def get_beijing_now_str():
"""获取北京时间的当前时间字符串"""
return datetime.now(BEIJING_TZ).strftime('%Y-%m-%d %H:%M:%S')
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.header import Header
from email.utils import formataddr
from typing import Optional, List, Dict, Any, Callable, Tuple
from io import BytesIO
import db_pool
from crypto_utils import encrypt_password, decrypt_password
from app_logger import get_logger
logger = get_logger("email_service")
def parse_datetime(dt_str: str) -> datetime:
"""解析数据库中的时间字符串,支持带微秒和不带微秒的格式"""
if not dt_str:
return BEIJING_TZ.localize(datetime(1970, 1, 1))
# 兼容 sqlite3 可能返回 datetime 对象的情况
if isinstance(dt_str, datetime):
return dt_str.astimezone(BEIJING_TZ) if dt_str.tzinfo else BEIJING_TZ.localize(dt_str)
text = str(dt_str)
# 尝试多种格式
formats = [
'%Y-%m-%d %H:%M:%S.%f', # 带微秒
'%Y-%m-%d %H:%M:%S', # 不带微秒
]
for fmt in formats:
try:
naive = datetime.strptime(text, fmt)
return BEIJING_TZ.localize(naive)
except ValueError:
continue
# 如果都失败,返回最小时间(视为过期)
return BEIJING_TZ.localize(datetime(1970, 1, 1))
# ============ 常量配置 ============
# 邮件类型
EMAIL_TYPE_REGISTER = 'register' # 注册验证
EMAIL_TYPE_RESET = 'reset' # 密码重置
EMAIL_TYPE_BIND = 'bind' # 邮箱绑定
EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知
EMAIL_TYPE_SECURITY_ALERT = 'security_alert' # 安全告警
# Token有效期
TOKEN_EXPIRE_REGISTER = 24 * 60 * 60 # 注册验证: 24小时
TOKEN_EXPIRE_RESET = 30 * 60 # 密码重置: 30分钟
TOKEN_EXPIRE_BIND = 60 * 60 # 邮箱绑定: 1小时
# 发送频率限制(秒)
RATE_LIMIT_REGISTER = 60 # 注册邮件: 1分钟
RATE_LIMIT_RESET = 5 * 60 # 重置邮件: 5分钟
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
def _resolve_base_url(base_url: Optional[str] = None) -> str:
"""解析系统基础URL按参数 -> 邮件设置 -> 配置文件 -> 默认值顺序。"""
if base_url:
return str(base_url).strip() or 'http://localhost:51233'
try:
settings = get_email_settings()
configured_url = (settings or {}).get('base_url', '')
if configured_url:
return configured_url
except Exception:
pass
try:
from app_config import Config
configured_url = getattr(Config, 'BASE_URL', '')
if configured_url:
return configured_url
except Exception:
pass
return 'http://localhost:51233'
def _load_email_template(template_filename: str, fallback_html: str) -> str:
"""读取邮件模板,读取失败时回退到内置模板。"""
template_path = os.path.join(os.path.dirname(__file__), 'templates', 'email', template_filename)
try:
with open(template_path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
return fallback_html
def _render_template(template: str, values: Dict[str, Any]) -> str:
"""替换模板变量,兼容 {{ key }} 和 {{key}} 两种写法。"""
rendered = template
for key, value in values.items():
value_text = '' if value is None else str(value)
rendered = rendered.replace(f'{{{{ {key} }}}}', value_text)
rendered = rendered.replace(f'{{{{{key}}}}}', value_text)
return rendered
def _mark_unused_tokens_as_used(user_id: int, token_type: str) -> 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, token_type),
)
conn.commit()
def _send_token_email(
*,
email: str,
username: str,
user_id: int,
base_url: Optional[str],
token_type: str,
rate_limit_error: str,
subject: str,
template_filename: str,
fallback_html: str,
url_path: str,
url_template_key: str,
text_template: str,
invalidate_existing_tokens: bool = False,
) -> Dict[str, Any]:
"""通用 token 邮件发送流程(注册/重置/绑定)。"""
if not check_rate_limit(email, token_type):
return {'success': False, 'error': rate_limit_error, 'token': None}
if invalidate_existing_tokens:
_mark_unused_tokens_as_used(user_id, token_type)
token = generate_email_token(email, token_type, user_id)
resolved_base_url = _resolve_base_url(base_url)
normalized_path = url_path if url_path.startswith('/') else f'/{url_path}'
action_url = f"{resolved_base_url.rstrip('/')}{normalized_path.format(token=token)}"
html_template = _load_email_template(template_filename, fallback_html)
html_body = _render_template(
html_template,
{
'username': username,
url_template_key: action_url,
},
)
text_body = text_template.format(username=username, action_url=action_url)
result = send_email(
to_email=email,
subject=subject,
body=text_body,
html_body=html_body,
email_type=token_type,
user_id=user_id,
)
if result.get('success'):
return {'success': True, 'error': '', 'token': token}
return {'success': False, 'error': result.get('error', '发送失败'), 'token': None}
def _task_notify_precheck(email: str, *, require_screenshots: bool = False, screenshots: Optional[List] = None) -> str:
"""任务通知发送前置检查,返回空字符串表示通过。"""
settings = get_email_settings()
if not settings.get('enabled', False):
return '邮件功能未启用'
if not settings.get('task_notify_enabled', False):
return '任务通知功能未启用'
if not email:
return '用户未设置邮箱'
if require_screenshots and not screenshots:
return '没有截图需要发送'
return ''
def _load_screenshot_data(screenshot_path: Optional[str], log_callback: Optional[Callable] = None) -> Tuple[Optional[bytes], Optional[str]]:
"""读取截图数据,失败时仅记录日志并继续。"""
if not screenshot_path or not os.path.exists(screenshot_path):
return None, None
try:
with open(screenshot_path, 'rb') as f:
return f.read(), os.path.basename(screenshot_path)
except Exception as e:
if log_callback:
log_callback(f"[邮件] 读取截图文件失败: {e}")
return None, None
def _render_task_complete_bodies(
*,
html_template: str,
username: str,
account_name: str,
browse_type: str,
total_items: int,
total_attachments: int,
complete_time: str,
batch_info: str,
screenshot_text: str,
) -> Tuple[str, str]:
"""渲染任务完成通知邮件内容。"""
html_body = _render_template(
html_template,
{
'username': username,
'account_name': account_name,
'browse_type': browse_type,
'total_items': total_items,
'total_attachments': total_attachments,
'complete_time': complete_time,
'batch_info': batch_info,
},
)
text_body = f"""
您好,{username}
您的浏览任务已完成。
账号:{account_name}
浏览类型:{browse_type}
浏览条目:{total_items}
附件数量:{total_attachments}
完成时间:{complete_time}
{screenshot_text}
"""
return html_body, text_body
def _build_batch_accounts_html_rows(screenshots: List[Dict[str, Any]]) -> str:
"""构建批次任务邮件中的账号详情表格。"""
rows = []
for item in screenshots:
rows.append(
f"""
<tr>
<td style="padding: 8px; border: 1px solid #ddd;">{item.get('account_name', '未知')}</td>
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">{item.get('items', 0)}</td>
<td style="padding: 8px; border: 1px solid #ddd; text-align: center;">{item.get('attachments', 0)}</td>
</tr>
"""
)
return ''.join(rows)
def _collect_existing_screenshot_paths(screenshots: List[Dict[str, Any]]) -> List[Tuple[str, str]]:
"""收集存在的截图路径与压缩包内文件名。"""
screenshot_paths: List[Tuple[str, str]] = []
for item in screenshots:
path = item.get('path')
if path and os.path.exists(path):
arcname = f"{item.get('account_name', 'screenshot')}_{os.path.basename(path)}"
screenshot_paths.append((path, arcname))
return screenshot_paths
def _build_zip_attachment_from_paths(screenshot_paths: List[Tuple[str, str]]) -> Tuple[Optional[bytes], Optional[str], str]:
"""从路径列表构建 ZIP 附件,返回 (zip_data, zip_filename, note)。"""
if not screenshot_paths:
return None, None, '本次无可用截图文件(可能截图失败或未启用截图)。'
import tempfile
zip_path = None
try:
with tempfile.NamedTemporaryFile(prefix='screenshots_', suffix='.zip', delete=False) as tmp:
zip_path = tmp.name
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_path, arcname in screenshot_paths:
try:
zf.write(file_path, arcname=arcname)
except Exception as e:
logger.warning(f"[邮件] 写入ZIP失败: {e}")
zip_size = os.path.getsize(zip_path) if zip_path and os.path.exists(zip_path) else 0
if zip_size <= 0:
return None, None, '本次无可用截图文件(可能截图失败或文件不存在)。'
if zip_size > MAX_ATTACHMENT_SIZE:
return None, None, f'截图打包文件过大({zip_size} bytes本次不附加附件。'
with open(zip_path, 'rb') as f:
zip_data = f.read()
zip_filename = f"screenshots_{datetime.now(BEIJING_TZ).strftime('%Y%m%d_%H%M%S')}.zip"
return zip_data, zip_filename, '截图已打包为ZIP附件请查收。'
except Exception as e:
logger.warning(f"[邮件] 打包截图失败: {e}")
return None, None, '截图打包失败,本次不附加附件。'
finally:
if zip_path and os.path.exists(zip_path):
try:
os.remove(zip_path)
except Exception:
pass
def _reset_smtp_daily_quota_if_needed(cursor, today: Optional[str] = None) -> int:
"""在日期切换后重置 SMTP 每日计数,返回更新记录数。"""
reset_day = today or get_beijing_today()
cursor.execute(
"""
UPDATE smtp_configs
SET daily_sent = 0, daily_reset_date = ?
WHERE daily_reset_date != ? OR daily_reset_date IS NULL OR daily_reset_date = ''
""",
(reset_day, reset_day),
)
return int(cursor.rowcount or 0)
def _build_smtp_admin_config(row, include_password: bool = False) -> Dict[str, Any]:
"""将 smtp_configs 全字段查询行转换为管理接口配置字典。"""
encrypted_password = row[8]
password_text = ''
if encrypted_password:
if include_password:
password_text = decrypt_password(encrypted_password)
else:
password_text = '******'
config = {
'id': row[0],
'name': row[1],
'enabled': bool(row[2]),
'is_primary': bool(row[3]),
'priority': row[4],
'host': row[5],
'port': row[6],
'username': row[7],
'password': password_text,
'has_password': bool(encrypted_password),
'use_ssl': bool(row[9]),
'use_tls': bool(row[10]),
'sender_name': row[11],
'sender_email': row[12],
'daily_limit': row[13],
'daily_sent': row[14],
'daily_reset_date': row[15],
'last_success_at': row[16],
'last_error': row[17],
'success_count': row[18],
'fail_count': row[19],
'created_at': row[20],
'updated_at': row[21],
}
total = (config['success_count'] or 0) + (config['fail_count'] or 0)
config['success_rate'] = round((config['success_count'] or 0) / total * 100, 1) if total > 0 else 0
return config
def _build_smtp_runtime_config(row) -> Dict[str, Any]:
"""将 smtp_configs 运行时查询行转换为发送字典(含每日限额字段)。"""
return {
'id': row[0],
'name': row[1],
'host': row[2],
'port': row[3],
'username': row[4],
'password': decrypt_password(row[5]) if row[5] else '',
'use_ssl': bool(row[6]),
'use_tls': bool(row[7]),
'sender_name': row[8],
'sender_email': row[9],
'daily_limit': row[10] or 0,
'daily_sent': row[11] or 0,
'is_primary': bool(row[12]),
}
def _strip_smtp_quota_fields(runtime_config: Dict[str, Any]) -> Dict[str, Any]:
"""移除内部限额字段,返回 send_email 使用的 SMTP 配置字典。"""
config = dict(runtime_config)
config.pop('daily_limit', None)
config.pop('daily_sent', None)
return config
# ============ 数据库操作 ============
def init_email_tables():
"""初始化邮件相关数据库表"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
# 1. SMTP配置表支持多配置
cursor.execute("""
CREATE TABLE IF NOT EXISTS smtp_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '默认配置',
enabled INTEGER DEFAULT 1,
is_primary INTEGER DEFAULT 0,
priority INTEGER DEFAULT 0,
host TEXT NOT NULL DEFAULT '',
port INTEGER DEFAULT 465,
username TEXT NOT NULL DEFAULT '',
password TEXT DEFAULT '',
use_ssl INTEGER DEFAULT 1,
use_tls INTEGER DEFAULT 0,
sender_name TEXT DEFAULT '自动化学习',
sender_email TEXT DEFAULT '',
daily_limit INTEGER DEFAULT 0,
daily_sent INTEGER DEFAULT 0,
daily_reset_date TEXT DEFAULT '',
last_success_at TIMESTAMP,
last_error TEXT DEFAULT '',
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 创建索引
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_smtp_configs_enabled
ON smtp_configs(enabled, priority)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_smtp_configs_primary
ON smtp_configs(is_primary)
""")
# 2. 全局邮件设置表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled INTEGER DEFAULT 0,
failover_enabled INTEGER DEFAULT 1,
register_verify_enabled INTEGER DEFAULT 0,
login_alert_enabled INTEGER DEFAULT 1,
task_notify_enabled INTEGER DEFAULT 0,
base_url TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 初始化默认设置
cursor.execute(
"""
INSERT OR IGNORE INTO email_settings (id, enabled, failover_enabled, updated_at)
VALUES (1, 0, 1, ?)
""",
(get_beijing_now_str(),),
)
# 3. 邮件验证Token表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
email TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
token_type TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_tokens_email ON email_tokens(email)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_tokens_expires ON email_tokens(expires_at)")
# 4. 邮件发送日志表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
smtp_config_id INTEGER,
email_to TEXT NOT NULL,
email_type TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
attachment_count INTEGER DEFAULT 0,
attachment_size INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (smtp_config_id) REFERENCES smtp_configs(id) ON DELETE SET NULL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_logs_user ON email_logs(user_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_logs_status ON email_logs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_logs_type ON email_logs(email_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_email_logs_created ON email_logs(created_at)")
# 5. 邮件发送统计表
cursor.execute("""
CREATE TABLE IF NOT EXISTS email_stats (
id INTEGER PRIMARY KEY DEFAULT 1,
total_sent INTEGER DEFAULT 0,
total_success INTEGER DEFAULT 0,
total_failed INTEGER DEFAULT 0,
register_sent INTEGER DEFAULT 0,
reset_sent INTEGER DEFAULT 0,
bind_sent INTEGER DEFAULT 0,
task_complete_sent INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 初始化统计记录
cursor.execute(
"""
INSERT OR IGNORE INTO email_stats (id, last_updated) VALUES (1, ?)
""",
(get_beijing_now_str(),),
)
conn.commit()
logger.info("[邮件服务] 数据库表初始化完成")
# ============ SMTP配置管理 ============
def get_email_settings() -> Dict[str, Any]:
"""获取全局邮件设置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT enabled, failover_enabled, register_verify_enabled, login_alert_enabled,
base_url, task_notify_enabled, updated_at
FROM email_settings WHERE id = 1
""")
row = cursor.fetchone()
if row:
return {
'enabled': bool(row[0]),
'failover_enabled': bool(row[1]),
'register_verify_enabled': bool(row[2]) if row[2] is not None else False,
'login_alert_enabled': bool(row[3]) if row[3] is not None else True,
'base_url': row[4] or '',
'task_notify_enabled': bool(row[5]) if row[5] is not None else False,
'updated_at': row[6]
}
return {
'enabled': False,
'failover_enabled': True,
'register_verify_enabled': False,
'login_alert_enabled': True,
'base_url': '',
'task_notify_enabled': False,
'updated_at': None
}
def update_email_settings(
enabled: bool,
failover_enabled: bool,
register_verify_enabled: bool = None,
login_alert_enabled: bool = None,
base_url: str = None,
task_notify_enabled: bool = None
) -> bool:
"""更新全局邮件设置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
# 构建动态更新语句
updates = ['enabled = ?', 'failover_enabled = ?', 'updated_at = ?']
params = [int(enabled), int(failover_enabled), get_beijing_now_str()]
if register_verify_enabled is not None:
updates.append('register_verify_enabled = ?')
params.append(int(register_verify_enabled))
if login_alert_enabled is not None:
updates.append('login_alert_enabled = ?')
params.append(int(login_alert_enabled))
if base_url is not None:
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)}
WHERE id = 1
""", params)
conn.commit()
return True
def get_smtp_configs(include_password: bool = False) -> List[Dict[str, Any]]:
"""获取所有SMTP配置列表"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
# 确保每天的配额在日期切换后能及时重置(即使当天没有触发邮件发送)
_reset_smtp_daily_quota_if_needed(cursor)
conn.commit()
cursor.execute("""
SELECT id, name, enabled, is_primary, priority, host, port,
username, password, use_ssl, use_tls, sender_name, sender_email,
daily_limit, daily_sent, daily_reset_date,
last_success_at, last_error, success_count, fail_count,
created_at, updated_at
FROM smtp_configs
ORDER BY is_primary DESC, priority ASC, id ASC
""")
return [_build_smtp_admin_config(row, include_password=include_password) for row in cursor.fetchall()]
def get_smtp_config(config_id: int, include_password: bool = False) -> Optional[Dict[str, Any]]:
"""获取单个SMTP配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
_reset_smtp_daily_quota_if_needed(cursor)
conn.commit()
cursor.execute("""
SELECT id, name, enabled, is_primary, priority, host, port,
username, password, use_ssl, use_tls, sender_name, sender_email,
daily_limit, daily_sent, daily_reset_date,
last_success_at, last_error, success_count, fail_count,
created_at, updated_at
FROM smtp_configs WHERE id = ?
""", (config_id,))
row = cursor.fetchone()
if not row:
return None
return _build_smtp_admin_config(row, include_password=include_password)
def create_smtp_config(data: Dict[str, Any]) -> int:
"""创建新的SMTP配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_str = get_beijing_now_str()
# 加密密码
password = encrypt_password(data.get('password', '')) if data.get('password') else ''
cursor.execute("""
INSERT INTO smtp_configs
(name, enabled, is_primary, priority, host, port, username, password,
use_ssl, use_tls, sender_name, sender_email, daily_limit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data.get('name', '默认配置'),
int(data.get('enabled', True)),
int(data.get('is_primary', False)),
data.get('priority', 0),
data.get('host', ''),
data.get('port', 465),
data.get('username', ''),
password,
int(data.get('use_ssl', True)),
int(data.get('use_tls', False)),
data.get('sender_name', '自动化学习'),
data.get('sender_email', ''),
data.get('daily_limit', 0),
now_str,
now_str,
))
config_id = cursor.lastrowid
conn.commit()
return config_id
def update_smtp_config(config_id: int, data: Dict[str, Any]) -> bool:
"""更新SMTP配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
updates = []
params = []
field_mapping = {
'name': 'name',
'enabled': 'enabled',
'priority': 'priority',
'host': 'host',
'port': 'port',
'username': 'username',
'use_ssl': 'use_ssl',
'use_tls': 'use_tls',
'sender_name': 'sender_name',
'sender_email': 'sender_email',
'daily_limit': 'daily_limit'
}
for key, db_field in field_mapping.items():
if key in data:
value = data[key]
if key in ('enabled', 'use_ssl', 'use_tls'):
value = int(value)
updates.append(f"{db_field} = ?")
params.append(value)
# 密码特殊处理:只有当提供了新密码时才更新
if 'password' in data and data['password'] and data['password'] != '******':
updates.append("password = ?")
params.append(encrypt_password(data['password']))
if not updates:
return False
updates.append("updated_at = ?")
params.append(get_beijing_now_str())
params.append(config_id)
cursor.execute(f"""
UPDATE smtp_configs SET {', '.join(updates)} WHERE id = ?
""", params)
conn.commit()
return cursor.rowcount > 0
def delete_smtp_config(config_id: int) -> bool:
"""删除SMTP配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM smtp_configs WHERE id = ?", (config_id,))
conn.commit()
return cursor.rowcount > 0
def set_primary_smtp_config(config_id: int) -> bool:
"""设置主SMTP配置"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
# 先取消所有主配置标记
cursor.execute("UPDATE smtp_configs SET is_primary = 0")
# 设置新的主配置
cursor.execute("""
UPDATE smtp_configs
SET is_primary = 1, updated_at = ?
WHERE id = ?
""", (get_beijing_now_str(), config_id))
conn.commit()
return cursor.rowcount > 0
def clear_primary_smtp_config() -> bool:
"""取消主SMTP配置清空所有 is_primary 标记)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE smtp_configs
SET is_primary = 0,
updated_at = ?
WHERE is_primary = 1
""",
(get_beijing_now_str(),),
)
conn.commit()
return True
def _fetch_candidate_smtp_configs(cursor, *, exclude_ids: Optional[List[int]] = None) -> List[Dict[str, Any]]:
"""获取可用 SMTP 候选配置(已过滤不可用/超额配置)。"""
excluded = [int(i) for i in (exclude_ids or []) if i is not None]
sql = """
SELECT id, name, host, port, username, password, use_ssl, use_tls,
sender_name, sender_email, daily_limit, daily_sent, is_primary
FROM smtp_configs
WHERE enabled = 1
"""
params: List[Any] = []
if excluded:
placeholders = ",".join(["?" for _ in excluded])
sql += f" AND id NOT IN ({placeholders})"
params.extend(excluded)
sql += " ORDER BY is_primary DESC, priority ASC, id ASC"
cursor.execute(sql, params)
candidates: List[Dict[str, Any]] = []
for row in cursor.fetchall() or []:
runtime_config = _build_smtp_runtime_config(row)
daily_limit = int(runtime_config.get('daily_limit') or 0)
daily_sent = int(runtime_config.get('daily_sent') or 0)
if daily_limit > 0 and daily_sent >= daily_limit:
continue
candidates.append(_strip_smtp_quota_fields(runtime_config))
return candidates
def _get_smtp_candidates(*, exclude_ids: Optional[List[int]] = None) -> List[Dict[str, Any]]:
"""读取并返回当前可发送的 SMTP 候选配置列表。"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
_reset_smtp_daily_quota_if_needed(cursor)
conn.commit()
return _fetch_candidate_smtp_configs(cursor, exclude_ids=exclude_ids)
def _get_available_smtp_config(failover: bool = True) -> Optional[Dict[str, Any]]:
"""获取首个可用 SMTP 配置。"""
candidates = _get_smtp_candidates()
return candidates[0] if candidates else None
def _update_smtp_stats(config_id: int, success: bool, error: str = ''):
"""更新SMTP配置的统计信息"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_str = get_beijing_now_str()
if success:
cursor.execute("""
UPDATE smtp_configs
SET daily_sent = daily_sent + 1,
success_count = success_count + 1,
last_success_at = ?,
last_error = '',
updated_at = ?
WHERE id = ?
""", (now_str, now_str, config_id))
else:
cursor.execute("""
UPDATE smtp_configs
SET fail_count = fail_count + 1,
last_error = ?,
updated_at = ?
WHERE id = ?
""", (error[:500], now_str, config_id))
conn.commit()
# ============ 邮件发送核心 ============
class EmailSender:
"""邮件发送器"""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.server = None
def connect(self) -> bool:
"""连接SMTP服务器"""
try:
if self.config['use_ssl']:
self.server = smtplib.SMTP_SSL(
self.config['host'],
self.config['port'],
timeout=30
)
else:
self.server = smtplib.SMTP(
self.config['host'],
self.config['port'],
timeout=30
)
if self.config['use_tls']:
self.server.starttls()
self.server.login(self.config['username'], self.config['password'])
return True
except Exception as e:
logger.warning(f"[邮件服务] SMTP连接失败 [{self.config['name']}]: {e}")
self.server = None
raise
def disconnect(self):
"""断开SMTP连接"""
if self.server:
try:
self.server.quit()
except Exception:
pass
self.server = None
def send(self, to_email: str, subject: str, body: str,
html_body: str = None, attachments: List[Dict] = None) -> bool:
"""
发送邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
body: 纯文本正文
html_body: HTML正文可选
attachments: 附件列表 [{'filename': 'xxx', 'data': bytes, 'mime_type': 'xxx'}]
"""
try:
if not self.server:
self.connect()
# 构建邮件
if html_body or attachments:
msg = MIMEMultipart('mixed')
# 添加正文
if html_body:
text_part = MIMEMultipart('alternative')
text_part.attach(MIMEText(body, 'plain', 'utf-8'))
text_part.attach(MIMEText(html_body, 'html', 'utf-8'))
msg.attach(text_part)
else:
msg.attach(MIMEText(body, 'plain', 'utf-8'))
# 添加附件
if attachments:
for att in attachments:
part = MIMEBase('application', 'octet-stream')
part.set_payload(att['data'])
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
'attachment',
filename=('utf-8', '', att['filename'])
)
msg.attach(part)
else:
msg = MIMEText(body, 'plain', 'utf-8')
# 设置邮件头
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = formataddr((
self.config['sender_name'],
self.config['sender_email'] or self.config['username']
))
msg['To'] = to_email
# 发送
self.server.sendmail(
self.config['sender_email'] or self.config['username'],
[to_email],
msg.as_string()
)
return True
except Exception as e:
logger.warning(f"[邮件服务] 发送失败: {e}")
raise
def _create_email_sender_from_config(config: Dict[str, Any]) -> EmailSender:
"""从 SMTP 配置创建发送器。"""
return EmailSender(
{
'name': config.get('name', ''),
'host': config.get('host', ''),
'port': config.get('port', 465),
'username': config.get('username', ''),
'password': config.get('password', ''),
'use_ssl': bool(config.get('use_ssl', True)),
'use_tls': bool(config.get('use_tls', False)),
'sender_name': config.get('sender_name', '自动化学习'),
'sender_email': config.get('sender_email', ''),
}
)
def _send_with_smtp_config(
*,
config: Dict[str, Any],
to_email: str,
subject: str,
body: str,
html_body: str = None,
attachments: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[bool, str]:
"""使用指定 SMTP 配置发送一封邮件。"""
sender = _create_email_sender_from_config(config)
try:
sender.connect()
sender.send(to_email, subject, body, html_body, attachments)
sender.disconnect()
return True, ''
except Exception as e:
sender.disconnect()
return False, str(e)
def send_email(
to_email: str,
subject: str,
body: str,
html_body: str = None,
attachments: List[Dict] = None,
email_type: str = '',
user_id: int = None,
log_callback: Callable = None
) -> Dict[str, Any]:
"""
发送邮件(带故障转移)
Returns:
{'success': bool, 'error': str, 'config_id': int}
"""
settings = get_email_settings()
if not settings['enabled']:
return {'success': False, 'error': '邮件功能未启用', 'config_id': None}
candidates = _get_smtp_candidates()
if not settings['failover_enabled']:
candidates = candidates[:1]
if not candidates:
return {'success': False, 'error': '没有可用的SMTP配置', 'config_id': None}
tried_configs: List[int] = []
last_error = ''
for config in candidates:
config_id = int(config.get('id') or 0)
tried_configs.append(config_id)
ok, error = _send_with_smtp_config(
config=config,
to_email=to_email,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
)
if ok:
_update_smtp_stats(config_id, True)
_log_email_send(user_id, config_id, to_email, email_type, subject, 'success', '', attachments)
_update_email_stats(email_type, True)
if log_callback:
log_callback(f"[邮件服务] 发送成功: {to_email} (使用: {config.get('name', '')})")
return {'success': True, 'error': '', 'config_id': config_id}
last_error = error
_update_smtp_stats(config_id, False, last_error)
if log_callback:
log_callback(f"[邮件服务] 发送失败 [{config.get('name', '')}]: {last_error}")
first_config_id = tried_configs[0] if tried_configs else None
_log_email_send(user_id, first_config_id, to_email, email_type, subject, 'failed', last_error, attachments)
_update_email_stats(email_type, False)
return {'success': False, 'error': last_error, 'config_id': first_config_id}
def _get_next_available_smtp_config(exclude_ids: List[int]) -> Optional[Dict[str, Any]]:
"""获取下一个可用的SMTP配置排除已尝试的"""
candidates = _get_smtp_candidates(exclude_ids=exclude_ids)
return candidates[0] if candidates else None
def test_smtp_config(config_id: int, test_email: str) -> Dict[str, Any]:
"""测试SMTP配置"""
config = get_smtp_config(config_id, include_password=True)
if not config:
return {'success': False, 'error': '配置不存在'}
ok, error = _send_with_smtp_config(
config=config,
to_email=test_email,
subject='自动化学习 - SMTP配置测试',
body=f'这是一封测试邮件。\n\n配置名称: {config["name"]}\nSMTP服务器: {config["host"]}:{config["port"]}\n\n如果您收到此邮件说明SMTP配置正确。',
)
if ok:
# 更新最后成功时间
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_str = get_beijing_now_str()
cursor.execute("""
UPDATE smtp_configs
SET last_success_at = ?, last_error = '', updated_at = ?
WHERE id = ?
""", (now_str, now_str, config_id))
conn.commit()
return {'success': True, 'error': ''}
# 记录错误
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE smtp_configs SET last_error = ? WHERE id = ?
""",
(error[:500], config_id),
)
conn.commit()
return {'success': False, 'error': error}
# ============ 邮件日志 ============
def _log_email_send(user_id: int, smtp_config_id: int, email_to: str,
email_type: str, subject: str, status: str,
error_message: str, attachments: List[Dict] = None):
"""记录邮件发送日志"""
attachment_count = len(attachments) if attachments else 0
attachment_size = sum(len(a.get('data', b'')) for a in attachments) if attachments else 0
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO email_logs
(user_id, smtp_config_id, email_to, email_type, subject, status,
error_message, attachment_count, attachment_size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, smtp_config_id, email_to, email_type, subject, status,
error_message, attachment_count, attachment_size, get_beijing_now_str()))
conn.commit()
def _update_email_stats(email_type: str, success: bool):
"""更新邮件统计"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_str = get_beijing_now_str()
type_field_map = {
EMAIL_TYPE_REGISTER: 'register_sent',
EMAIL_TYPE_RESET: 'reset_sent',
EMAIL_TYPE_BIND: 'bind_sent',
EMAIL_TYPE_TASK_COMPLETE: 'task_complete_sent'
}
type_field = type_field_map.get(email_type, '')
if success:
if type_field:
cursor.execute(f"""
UPDATE email_stats
SET total_sent = total_sent + 1,
total_success = total_success + 1,
{type_field} = {type_field} + 1,
last_updated = ?
WHERE id = 1
""", (now_str,))
else:
cursor.execute("""
UPDATE email_stats
SET total_sent = total_sent + 1,
total_success = total_success + 1,
last_updated = ?
WHERE id = 1
""", (now_str,))
else:
cursor.execute("""
UPDATE email_stats
SET total_sent = total_sent + 1,
total_failed = total_failed + 1,
last_updated = ?
WHERE id = 1
""", (now_str,))
conn.commit()
def reset_smtp_daily_quota():
"""重置所有SMTP配置的每日发送计数北京时间凌晨0点调用"""
today = get_beijing_today()
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE smtp_configs
SET daily_sent = 0, daily_reset_date = ?
WHERE daily_reset_date != ? OR daily_reset_date IS NULL OR daily_reset_date = ''
""", (today, today))
updated = cursor.rowcount
conn.commit()
if updated > 0:
logger.info(f"[邮件服务] 已重置 {updated} 个SMTP配置的每日配额")
return updated
def get_email_stats() -> Dict[str, Any]:
"""获取邮件统计"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT total_sent, total_success, total_failed,
register_sent, reset_sent, bind_sent, task_complete_sent,
last_updated
FROM email_stats WHERE id = 1
""")
row = cursor.fetchone()
if row:
return {
'total_sent': row[0],
'total_success': row[1],
'total_failed': row[2],
'register_sent': row[3],
'reset_sent': row[4],
'bind_sent': row[5],
'task_complete_sent': row[6],
'last_updated': row[7],
'success_rate': round(row[1] / row[0] * 100, 1) if row[0] > 0 else 0
}
return {}
def get_email_logs(page: int = 1, page_size: int = 20,
email_type: str = None, status: str = None) -> Dict[str, Any]:
"""获取邮件日志(分页)"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
where_clauses = []
params = []
if email_type:
where_clauses.append("l.email_type = ?")
params.append(email_type)
if status:
where_clauses.append("l.status = ?")
params.append(status)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
# 获取总数
cursor.execute(f"SELECT COUNT(*) FROM email_logs l {where_sql}", params)
total = cursor.fetchone()[0]
# 获取分页数据
offset = (page - 1) * page_size
cursor.execute(f"""
SELECT l.id, l.user_id, l.smtp_config_id, l.email_to, l.email_type,
l.subject, l.status, l.error_message, l.attachment_count,
l.attachment_size, l.created_at, u.username, s.name as smtp_name
FROM email_logs l
LEFT JOIN users u ON l.user_id = u.id
LEFT JOIN smtp_configs s ON l.smtp_config_id = s.id
{where_sql}
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?
""", params + [page_size, offset])
logs = []
for row in cursor.fetchall():
logs.append({
'id': row[0],
'user_id': row[1],
'smtp_config_id': row[2],
'email_to': row[3],
'email_type': row[4],
'subject': row[5],
'status': row[6],
'error_message': row[7],
'attachment_count': row[8],
'attachment_size': row[9],
'created_at': row[10],
'username': row[11],
'smtp_name': row[12]
})
return {
'logs': logs,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size
}
def cleanup_email_logs(days: int = 30) -> int:
"""清理过期邮件日志"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cutoff = (datetime.now(BEIJING_TZ) - timedelta(days=int(days))).strftime('%Y-%m-%d %H:%M:%S')
cursor.execute(
"""
DELETE FROM email_logs
WHERE datetime(created_at) < datetime(?)
""",
(cutoff,),
)
deleted = cursor.rowcount
conn.commit()
return deleted
# ============ Token管理 ============
def generate_email_token(email: str, token_type: str, user_id: int = None) -> str:
"""生成邮件验证Token"""
token = secrets.token_urlsafe(32)
# 设置过期时间
expire_seconds = {
EMAIL_TYPE_REGISTER: TOKEN_EXPIRE_REGISTER,
EMAIL_TYPE_RESET: TOKEN_EXPIRE_RESET,
EMAIL_TYPE_BIND: TOKEN_EXPIRE_BIND
}.get(token_type, TOKEN_EXPIRE_REGISTER)
now = datetime.now(BEIJING_TZ)
now_str = now.strftime('%Y-%m-%d %H:%M:%S')
expires_at_str = (now + timedelta(seconds=expire_seconds)).strftime('%Y-%m-%d %H:%M:%S')
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO email_tokens (user_id, email, token, token_type, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(user_id, email, token, token_type, expires_at_str, now_str),
)
conn.commit()
return token
def _get_email_token_payload(token: str, token_type: str) -> Optional[Dict[str, Any]]:
"""获取并校验邮件Token不消费"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, user_id, email, expires_at, used
FROM email_tokens
WHERE token = ? AND token_type = ?
""",
(token, token_type),
)
row = cursor.fetchone()
if not row:
return None
token_id, user_id, email, expires_at, used = row
if used:
return None
if parse_datetime(expires_at) < datetime.now(BEIJING_TZ):
return None
return {'token_id': token_id, 'user_id': user_id, 'email': email}
def consume_email_token(token_id: int) -> bool:
"""将邮件Token标记为已使用。"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"""
UPDATE email_tokens
SET used = 1
WHERE id = ? AND used = 0
""",
(int(token_id),),
)
conn.commit()
return cursor.rowcount > 0
def verify_email_token(token: str, token_type: str, *, consume: bool = True) -> Optional[Dict[str, Any]]:
"""
验证Token
Args:
token: token字符串
token_type: token类型
consume: 是否在验证成功后立刻消费默认True
Returns:
consume=True: {'user_id': int, 'email': str}
consume=False: {'token_id': int, 'user_id': int, 'email': str}
失败返回 None
"""
payload = _get_email_token_payload(token, token_type)
if not payload:
return None
if consume:
if not consume_email_token(payload['token_id']):
return None
return {'user_id': payload['user_id'], 'email': payload['email']}
return payload
def check_rate_limit(email: str, token_type: str) -> bool:
"""
检查发送频率限制
Returns:
True = 可以发送, False = 受限
"""
limit_seconds = {
EMAIL_TYPE_REGISTER: RATE_LIMIT_REGISTER,
EMAIL_TYPE_RESET: RATE_LIMIT_RESET,
EMAIL_TYPE_BIND: RATE_LIMIT_BIND
}.get(token_type, 60)
with db_pool.get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT created_at FROM email_tokens
WHERE email = ? AND token_type = ?
ORDER BY created_at DESC
LIMIT 1
""", (email, token_type))
row = cursor.fetchone()
if not row:
return True
last_sent = parse_datetime(row[0])
elapsed = (datetime.now(BEIJING_TZ) - last_sent).total_seconds()
return elapsed >= limit_seconds
def cleanup_expired_tokens() -> int:
"""清理过期Token"""
with db_pool.get_db() as conn:
cursor = conn.cursor()
now_str = get_beijing_now_str()
cursor.execute(
"""
DELETE FROM email_tokens
WHERE datetime(expires_at) < datetime(?)
""",
(now_str,),
)
deleted = cursor.rowcount
conn.commit()
return deleted
# ============ 注册验证邮件 ============
def send_register_verification_email(
email: str,
username: str,
user_id: int,
base_url: str = None
) -> Dict[str, Any]:
"""
发送注册验证邮件
Args:
email: 用户邮箱
username: 用户名
user_id: 用户ID
base_url: 网站基础URL
Returns:
{'success': bool, 'error': str, 'token': str}
"""
fallback_html = """
<html>
<body>
<h1>邮箱验证</h1>
<p>您好,{{ username }}</p>
<p>请点击下面的链接验证您的邮箱地址:</p>
<p><a href="{{ verify_url }}">{{ verify_url }}</a></p>
<p>此链接24小时内有效。</p>
</body>
</html>
"""
text_template = """
您好,{username}
感谢您注册自动化学习。请点击下面的链接验证您的邮箱地址:
{action_url}
此链接24小时内有效。
如果您没有注册过账号,请忽略此邮件。
"""
return _send_token_email(
email=email,
username=username,
user_id=user_id,
base_url=base_url,
token_type=EMAIL_TYPE_REGISTER,
rate_limit_error='发送太频繁,请稍后再试',
subject='【自动化学习】邮箱验证',
template_filename='register.html',
fallback_html=fallback_html,
url_path='/api/verify-email/{token}',
url_template_key='verify_url',
text_template=text_template,
)
def resend_register_verification_email(user_id: int, email: str, username: str) -> Dict[str, Any]:
"""
重发注册验证邮件
Args:
user_id: 用户ID
email: 用户邮箱
username: 用户名
Returns:
{'success': bool, 'error': str}
"""
# 检查是否有未过期的token
_mark_unused_tokens_as_used(user_id, EMAIL_TYPE_REGISTER)
# 发送新的验证邮件
return send_register_verification_email(email, username, user_id)
# ============ 密码重置邮件 ============
def send_password_reset_email(
email: str,
username: str,
user_id: int,
base_url: str = None
) -> Dict[str, Any]:
"""
发送密码重置邮件
Args:
email: 用户邮箱
username: 用户名
user_id: 用户ID
base_url: 网站基础URL
Returns:
{'success': bool, 'error': str, 'token': str}
"""
fallback_html = """
<html>
<body>
<h1>密码重置</h1>
<p>您好,{{ username }}</p>
<p>请点击下面的链接重置您的密码:</p>
<p><a href="{{ reset_url }}">{{ reset_url }}</a></p>
<p>此链接30分钟内有效。</p>
</body>
</html>
"""
text_template = """
您好,{username}
我们收到了您的密码重置请求。请点击下面的链接重置您的密码:
{action_url}
此链接30分钟内有效。
如果您没有申请过密码重置,请忽略此邮件。
"""
return _send_token_email(
email=email,
username=username,
user_id=user_id,
base_url=base_url,
token_type=EMAIL_TYPE_RESET,
rate_limit_error='发送太频繁请5分钟后再试',
subject='【自动化学习】密码重置',
template_filename='reset_password.html',
fallback_html=fallback_html,
url_path='/reset-password/{token}',
url_template_key='reset_url',
text_template=text_template,
invalidate_existing_tokens=True,
)
def verify_password_reset_token(token: str) -> Optional[Dict[str, Any]]:
"""
验证密码重置Token不标记为已使用
Returns:
成功返回 {'user_id': int, 'email': str},失败返回 None
"""
return verify_email_token(token, EMAIL_TYPE_RESET, consume=False)
def confirm_password_reset(token: str) -> Optional[Dict[str, Any]]:
"""
确认密码重置标记Token为已使用
Returns:
成功返回 {'user_id': int, 'email': str},失败返回 None
"""
result = verify_password_reset_token(token)
if not result:
return None
if not consume_email_token(result['token_id']):
return None
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}
"""
fallback_html = """
<html>
<body>
<h1>邮箱绑定验证</h1>
<p>您好,{{ username }}</p>
<p>请点击下面的链接完成邮箱绑定:</p>
<p><a href="{{ verify_url }}">{{ verify_url }}</a></p>
<p>此链接1小时内有效。</p>
</body>
</html>
"""
text_template = """
您好,{username}
您正在绑定此邮箱到您的账号。请点击下面的链接完成验证:
{action_url}
此链接1小时内有效。
如果这不是您的操作,请忽略此邮件。
"""
return _send_token_email(
email=email,
username=username,
user_id=user_id,
base_url=base_url,
token_type=EMAIL_TYPE_BIND,
rate_limit_error='发送太频繁请1分钟后再试',
subject='【自动化学习】邮箱绑定验证',
template_filename='bind_email.html',
fallback_html=fallback_html,
url_path='/api/verify-bind-email/{token}',
url_template_key='verify_url',
text_template=text_template,
invalidate_existing_tokens=True,
)
def verify_bind_email_token(token: str, *, consume: bool = True) -> Optional[Dict[str, Any]]:
"""
验证邮箱绑定Token
Returns:
成功返回 {'user_id': int, 'email': str},失败返回 None
"""
return verify_email_token(token, EMAIL_TYPE_BIND, consume=consume)
def send_security_alert_email(
email: str,
username: str,
ip_address: str,
user_agent: str,
new_ip: bool,
new_device: bool,
user_id: int = None,
) -> Dict[str, Any]:
"""发送登录安全提醒邮件(低侵入,不影响登录)。"""
settings = get_email_settings()
if not settings.get("enabled", False):
return {"success": False, "error": "邮件功能未启用"}
reason_parts = []
if new_ip:
reason_parts.append("新的登录IP")
if new_device:
reason_parts.append("新的登录设备")
reason_text = "".join(reason_parts) if reason_parts else "异常登录"
subject = "账号安全提醒"
now_str = get_beijing_now_str()
ip_text = ip_address or "未知"
ua_text = user_agent or "未知"
text_body = (
f"您好,{username}\n\n"
f"我们检测到 {reason_text}\n"
f"时间:{now_str}\n"
f"IP{ip_text}\n"
f"设备信息:{ua_text}\n\n"
"如果这不是您本人操作,请尽快修改密码并联系管理员。\n"
)
html_body = f"""
<html>
<body>
<h2>账号安全提醒</h2>
<p>您好,{username}</p>
<p>我们检测到 <strong>{reason_text}</strong>。</p>
<ul>
<li>时间:{now_str}</li>
<li>IP{ip_text}</li>
<li>设备信息:{ua_text}</li>
</ul>
<p>如果这不是您本人操作,请尽快修改密码并联系管理员。</p>
</body>
</html>
"""
return send_email(
to_email=email,
subject=subject,
body=text_body,
html_body=html_body,
email_type=EMAIL_TYPE_SECURITY_ALERT,
user_id=user_id,
)
# ============ 异步发送队列 ============
class EmailQueue:
"""邮件异步发送队列"""
def __init__(self, workers: int = QUEUE_WORKERS, max_size: int = QUEUE_MAX_SIZE):
self.queue = queue.Queue(maxsize=max_size)
self.workers = []
self.running = False
self.worker_count = workers
def start(self):
"""启动队列工作线程"""
if self.running:
return
self.running = True
for i in range(self.worker_count):
worker = threading.Thread(target=self._worker, daemon=True, name=f"EmailWorker-{i+1}")
worker.start()
self.workers.append(worker)
logger.info(f"[邮件服务] 异步队列已启动 ({self.worker_count}个工作线程)")
def stop(self):
"""停止队列"""
self.running = False
# 发送停止信号
for _ in self.workers:
try:
self.queue.put(None, timeout=1)
except queue.Full:
pass
# 等待工作线程结束
for worker in self.workers:
worker.join(timeout=5)
self.workers.clear()
logger.info("[邮件服务] 异步队列已停止")
def _worker(self):
"""工作线程"""
while self.running:
try:
task = self.queue.get(timeout=5)
if task is None:
break
self._process_task(task)
except queue.Empty:
continue
except Exception as e:
logger.exception(f"[邮件服务] 队列工作线程错误: {e}")
def _process_task(self, task: Dict):
"""处理邮件任务"""
try:
func = task.get('callable')
if callable(func):
args = task.get('args') or ()
kwargs = task.get('kwargs') or {}
result = func(*args, **kwargs)
if task.get('callback'):
task['callback'](result)
return
result = send_email(
to_email=task['to_email'],
subject=task['subject'],
body=task['body'],
html_body=task.get('html_body'),
attachments=task.get('attachments'),
email_type=task.get('email_type', ''),
user_id=task.get('user_id'),
log_callback=task.get('log_callback')
)
# 执行回调
if task.get('callback'):
task['callback'](result)
except Exception as e:
logger.exception(f"[邮件服务] 处理邮件任务失败: {e}")
if task.get('callback'):
task['callback']({'success': False, 'error': str(e)})
def enqueue(self, to_email: str, subject: str, body: str,
html_body: str = None, attachments: List[Dict] = None,
email_type: str = '', user_id: int = None,
callback: Callable = None, log_callback: Callable = None) -> bool:
"""
将邮件任务加入队列
Returns:
是否成功加入队列
"""
try:
self.queue.put({
'to_email': to_email,
'subject': subject,
'body': body,
'html_body': html_body,
'attachments': attachments,
'email_type': email_type,
'user_id': user_id,
'callback': callback,
'log_callback': log_callback
}, timeout=5)
return True
except queue.Full:
logger.warning("[邮件服务] 邮件队列已满")
return False
def enqueue_callable(self, func: Callable, args=None, kwargs=None, callback: Callable = None) -> bool:
"""将可调用任务加入队列(用于复杂邮件/打包等逻辑异步化)"""
try:
self.queue.put({
'callable': func,
'args': args or (),
'kwargs': kwargs or {},
'callback': callback
}, timeout=5)
return True
except queue.Full:
logger.warning("[邮件服务] 邮件队列已满")
return False
@property
def pending_count(self) -> int:
"""队列中待处理的任务数"""
return self.queue.qsize()
# 全局队列实例
_email_queue: Optional[EmailQueue] = None
_queue_lock = threading.Lock()
def get_email_queue() -> EmailQueue:
"""获取全局邮件队列(单例)"""
global _email_queue
with _queue_lock:
if _email_queue is None:
_email_queue = EmailQueue()
_email_queue.start()
return _email_queue
def shutdown_email_queue():
"""关闭邮件队列"""
global _email_queue
with _queue_lock:
if _email_queue:
_email_queue.stop()
_email_queue = None
# ============ 便捷函数 ============
def send_email_async(to_email: str, subject: str, body: str,
html_body: str = None, attachments: List[Dict] = None,
email_type: str = '', user_id: int = None,
callback: Callable = None, log_callback: Callable = None) -> bool:
"""异步发送邮件"""
queue = get_email_queue()
return queue.enqueue(
to_email=to_email,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
email_type=email_type,
user_id=user_id,
callback=callback,
log_callback=log_callback
)
def create_zip_attachment(files: List[Dict[str, Any]], zip_filename: str = 'screenshots.zip') -> Dict:
"""
创建ZIP附件
Args:
files: [{'filename': 'xxx.png', 'data': bytes}]
zip_filename: ZIP文件名
Returns:
{'filename': 'xxx.zip', 'data': bytes}
"""
buffer = BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in files:
zf.writestr(f['filename'], f['data'])
return {
'filename': zip_filename,
'data': buffer.getvalue()
}
# ============ 任务完成通知邮件 ============
def _send_email_and_collect(
*,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
attachments: Optional[List[Dict[str, Any]]] = None,
email_type: str,
user_id: Optional[int],
log_callback: Optional[Callable] = None,
success_log: Optional[str] = None,
) -> Tuple[bool, str]:
result = send_email(
to_email=to_email,
subject=subject,
body=body,
html_body=html_body,
attachments=attachments,
email_type=email_type,
user_id=user_id,
log_callback=log_callback,
)
if result.get('success'):
if success_log and log_callback:
log_callback(success_log)
return True, ''
return False, str(result.get('error') or '发送失败')
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}
"""
precheck_error = _task_notify_precheck(email)
if precheck_error:
return {'success': False, 'error': precheck_error, 'emails_sent': 0}
complete_time = get_beijing_now_str()
screenshot_data, screenshot_filename = _load_screenshot_data(screenshot_path, log_callback)
html_template = _load_email_template(
'task_complete.html',
"""
<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 = ''
is_oversized_attachment = bool(screenshot_data and len(screenshot_data) > MAX_ATTACHMENT_SIZE)
if is_oversized_attachment:
html_body, text_body = _render_task_complete_bodies(
html_template=html_template,
username=username,
account_name=account_name,
browse_type=browse_type,
total_items=total_items,
total_attachments=total_attachments,
complete_time=complete_time,
batch_info='(由于文件较大,截图将单独发送)',
screenshot_text='截图将在下一封邮件中发送。',
)
ok, error = _send_email_and_collect(
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,
success_log='[邮件] 任务通知已发送',
)
if ok:
emails_sent += 1
else:
last_error = error
attachment = [{'filename': screenshot_filename, 'data': screenshot_data}]
ok, error = _send_email_and_collect(
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,
success_log='[邮件] 截图附件已发送',
)
if ok:
emails_sent += 1
else:
last_error = error
else:
attachments = [{'filename': screenshot_filename, 'data': screenshot_data}] if screenshot_data else None
html_body, text_body = _render_task_complete_bodies(
html_template=html_template,
username=username,
account_name=account_name,
browse_type=browse_type,
total_items=total_items,
total_attachments=total_attachments,
complete_time=complete_time,
batch_info='',
screenshot_text='截图已附在邮件中。' if screenshot_data else '',
)
ok, error = _send_email_and_collect(
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,
success_log='[邮件] 任务通知已发送',
)
if ok:
emails_sent += 1
else:
last_error = 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
):
"""异步发送任务完成通知邮件"""
queue = get_email_queue()
ok = queue.enqueue_callable(
send_task_complete_email,
args=(user_id, email, username, account_name, browse_type,
total_items, total_attachments, screenshot_path, log_callback),
)
if (not ok) and log_callback:
log_callback("[邮件] 邮件队列已满,任务通知未发送")
def _summarize_batch_screenshots(screenshots: List[Dict[str, Any]]) -> Tuple[int, int, int]:
total_items_sum = sum(int(s.get('items', 0) or 0) for s in screenshots)
total_attachments_sum = sum(int(s.get('attachments', 0) or 0) for s in screenshots)
account_count = len(screenshots)
return total_items_sum, total_attachments_sum, account_count
def _render_batch_task_complete_html(
*,
username: str,
schedule_name: str,
browse_type: str,
complete_time: str,
account_count: int,
total_items_sum: int,
total_attachments_sum: int,
accounts_html: str,
) -> str:
return f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #667eea;">定时任务完成通知</h2>
<p>您好,{username}</p>
<p>您的定时任务 <strong>{schedule_name}</strong> 已完成执行。</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin: 15px 0;">
<p style="margin: 5px 0;"><strong>浏览类型:</strong>{browse_type}</p>
<p style="margin: 5px 0;"><strong>执行账号:</strong>{account_count} 个</p>
<p style="margin: 5px 0;"><strong>总浏览条目:</strong>{total_items_sum} 条</p>
<p style="margin: 5px 0;"><strong>总附件数量:</strong>{total_attachments_sum} 个</p>
<p style="margin: 5px 0;"><strong>完成时间:</strong>{complete_time}</p>
</div>
<h3 style="color: #667eea; margin-top: 20px;">账号执行详情</h3>
<table style="width: 100%; border-collapse: collapse; margin: 10px 0;">
<tr style="background: #667eea; color: white;">
<th style="padding: 10px; border: 1px solid #ddd;">账号名称</th>
<th style="padding: 10px; border: 1px solid #ddd;">浏览条目</th>
<th style="padding: 10px; border: 1px solid #ddd;">附件数量</th>
</tr>
{accounts_html}
</table>
<p style="color: #666; font-size: 12px; margin-top: 20px;">
截图已打包为ZIP附件请查收。
</p>
</div>
</body>
</html>
"""
def send_batch_task_complete_email(
user_id: int,
email: str,
username: str,
schedule_name: str,
browse_type: str,
screenshots: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
发送批次任务完成通知邮件(多账号截图打包)
Args:
user_id: 用户ID
email: 收件人邮箱
username: 用户名
schedule_name: 定时任务名称
browse_type: 浏览类型
screenshots: 截图列表 [{'account_name': x, 'path': y, 'items': n, 'attachments': m}, ...]
Returns:
{'success': bool, 'error': str}
"""
precheck_error = _task_notify_precheck(email, require_screenshots=True, screenshots=screenshots)
if precheck_error:
return {'success': False, 'error': precheck_error}
complete_time = get_beijing_now_str()
total_items_sum, total_attachments_sum, account_count = _summarize_batch_screenshots(screenshots)
accounts_html = _build_batch_accounts_html_rows(screenshots)
html_content = _render_batch_task_complete_html(
username=username,
schedule_name=schedule_name,
browse_type=browse_type,
complete_time=complete_time,
account_count=account_count,
total_items_sum=total_items_sum,
total_attachments_sum=total_attachments_sum,
accounts_html=accounts_html,
)
screenshot_paths = _collect_existing_screenshot_paths(screenshots)
zip_data, zip_filename, attachment_note = _build_zip_attachment_from_paths(screenshot_paths)
# 将附件说明写入邮件内容
html_content = html_content.replace("截图已打包为ZIP附件请查收。", attachment_note)
# 发送邮件
attachments = []
if zip_data and zip_filename:
attachments.append({
'filename': zip_filename,
'data': zip_data,
'mime_type': 'application/zip'
})
ok, error = _send_email_and_collect(
to_email=email,
subject=f'【自动化学习】定时任务完成 - {schedule_name}',
body='',
html_body=html_content,
attachments=attachments,
email_type='batch_task_complete',
user_id=user_id,
)
if ok:
return {'success': True}
return {'success': False, 'error': error or '发送失败'}
def send_batch_task_complete_email_async(
user_id: int,
email: str,
username: str,
schedule_name: str,
browse_type: str,
screenshots: List[Dict[str, Any]]
):
"""异步发送批次任务完成通知邮件"""
queue = get_email_queue()
ok = queue.enqueue_callable(
send_batch_task_complete_email,
args=(user_id, email, username, schedule_name, browse_type, screenshots),
)
if not ok:
logger.warning("[邮件] 邮件队列已满,批次任务邮件未发送")
# ============ 初始化 ============
def init_email_service():
"""初始化邮件服务"""
init_email_tables()
get_email_queue()
try:
logger.info("[邮件服务] 初始化完成")
except Exception:
print("[邮件服务] 初始化完成")
if __name__ == '__main__':
# 测试代码
init_email_tables()
print("邮件服务模块测试完成")