379 lines
14 KiB
Python
379 lines
14 KiB
Python
"""邮箱验证模块 - 通过 Roundcube Webmail API 获取验证码。"""
|
||
|
||
import html
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
|
||
from loguru import logger
|
||
import requests
|
||
|
||
|
||
# Roundcube Webmail 地址。可通过环境变量覆盖。
|
||
ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL", "http://111.229.206.54:8000/")
|
||
|
||
|
||
class EmailVerifier:
|
||
"""邮箱验证器(Roundcube API)。"""
|
||
|
||
def __init__(
|
||
self,
|
||
imap_server: str = "",
|
||
imap_port: int = 143,
|
||
username: str = "",
|
||
password: str = "",
|
||
timeout: float = 10,
|
||
mailbox: str = "INBOX",
|
||
lookback_minutes: int = 10,
|
||
max_messages: int = 20,
|
||
use_ssl: bool = False,
|
||
roundcube_url: str = "",
|
||
):
|
||
# 兼容旧参数名
|
||
self.imap_server = imap_server
|
||
self.imap_port = imap_port
|
||
self.username = username # 邮箱地址
|
||
self.password = password # 邮箱密码
|
||
self.timeout = timeout
|
||
self.roundcube_url = roundcube_url or ROUNDCUBE_URL
|
||
|
||
# Roundcube 会话(懒初始化)
|
||
self._rc_session: Optional[requests.Session] = None
|
||
self._rc_token: Optional[str] = None
|
||
self._rc_logged_in = False
|
||
|
||
def connect(self) -> None:
|
||
"""兼容接口"""
|
||
pass
|
||
|
||
def disconnect(self) -> None:
|
||
"""关闭 Roundcube 会话"""
|
||
if self._rc_session:
|
||
try:
|
||
self._rc_session.get(
|
||
f"{self.roundcube_url}?_task=logout",
|
||
timeout=5,
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._rc_session = None
|
||
self._rc_logged_in = False
|
||
|
||
# ── Roundcube 会话管理 ──────────────────────────────────
|
||
|
||
def _ensure_roundcube_session(self) -> bool:
|
||
"""确保已登录 Roundcube,返回是否成功"""
|
||
if self._rc_logged_in and self._rc_session and self._rc_token:
|
||
return True
|
||
|
||
try:
|
||
session = requests.Session()
|
||
session.headers.update({
|
||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||
})
|
||
|
||
# 1. 访问首页获取 token
|
||
resp = session.get(self.roundcube_url, timeout=self.timeout)
|
||
token_match = re.search(r'name="_token"\s+value="([^"]+)"', resp.text)
|
||
if not token_match:
|
||
logger.warning("Roundcube: 未获取到登录 token")
|
||
return False
|
||
token = token_match.group(1)
|
||
|
||
# 2. 登录
|
||
resp = session.post(
|
||
f"{self.roundcube_url}?_task=login",
|
||
data={
|
||
"_token": token,
|
||
"_task": "login",
|
||
"_action": "login",
|
||
"_user": self.username,
|
||
"_pass": self.password,
|
||
},
|
||
timeout=self.timeout,
|
||
allow_redirects=True,
|
||
)
|
||
|
||
# 3. 检查登录是否成功(成功会跳到 ?_task=mail)
|
||
new_token_match = re.search(r'request_token["\s:]+["\']([^"\']+)', resp.text)
|
||
if not new_token_match:
|
||
logger.warning("Roundcube: 登录失败(未找到 request_token)")
|
||
return False
|
||
|
||
self._rc_session = session
|
||
self._rc_token = new_token_match.group(1)
|
||
self._rc_logged_in = True
|
||
logger.debug(f"Roundcube: 登录成功 ({self.username})")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Roundcube: 登录异常: {e}")
|
||
return False
|
||
|
||
# ── Roundcube 邮件列表(含精确日期) ────────────────────
|
||
|
||
def _rc_fetch_mail_list(self) -> list[dict]:
|
||
"""
|
||
通过 Roundcube JSON API 获取 INBOX 邮件列表。
|
||
返回 [{"uid": 2, "subject": "验证码 - 斗鱼", "date": "今天 09:02"}, ...]
|
||
"""
|
||
if not self._ensure_roundcube_session():
|
||
return []
|
||
|
||
try:
|
||
resp = self._rc_session.get(
|
||
f"{self.roundcube_url}?_task=mail&_action=list"
|
||
f"&_mbox=INBOX&_remote=1&_unlock=1",
|
||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||
timeout=self.timeout,
|
||
)
|
||
# Roundcube 返回的 JSON 可能带 BOM,需用 utf-8-sig 解码
|
||
text = resp.content.decode("utf-8-sig")
|
||
data = __import__("json").loads(text)
|
||
|
||
# 从 exec 字段中解析 add_message_row 调用
|
||
exec_text = data.get("exec", "")
|
||
messages = []
|
||
|
||
# 匹配: this.add_message_row(UID, {subject:"...", fromto:"...", date:"今天 09:02", size:"5 KB"}, {...}, ...);
|
||
for m in re.finditer(
|
||
r'add_message_row\((\d+),\s*(\{[^}]+\})',
|
||
exec_text,
|
||
):
|
||
uid = int(m.group(1))
|
||
props_str = m.group(2)
|
||
# 提取各字段
|
||
subject_m = re.search(r'"subject"\s*:\s*"([^"]*)"', props_str)
|
||
date_m = re.search(r'"date"\s*:\s*"([^"]*)"', props_str)
|
||
|
||
messages.append({
|
||
"uid": uid,
|
||
"subject": subject_m.group(1) if subject_m else "",
|
||
"date": date_m.group(1) if date_m else "",
|
||
})
|
||
|
||
return messages
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Roundcube: 获取邮件列表失败: {e}")
|
||
return []
|
||
|
||
# ── Roundcube 读取邮件内容 ─────────────────────────────
|
||
|
||
def _rc_fetch_email_body(self, uid: int) -> Optional[str]:
|
||
"""通过 Roundcube 读取指定 UID 邮件的正文"""
|
||
if not self._ensure_roundcube_session():
|
||
return None
|
||
|
||
try:
|
||
resp = self._rc_session.get(
|
||
f"{self.roundcube_url}?_task=mail&_action=show"
|
||
f"&_mbox=INBOX&_uid={uid}",
|
||
timeout=self.timeout,
|
||
)
|
||
return resp.text
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Roundcube: 读取邮件 {uid} 失败: {e}")
|
||
return None
|
||
|
||
# ── Roundcube 日期解析 ─────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _parse_rc_date(date_str: str) -> Optional[datetime]:
|
||
"""
|
||
解析 Roundcube 返回的日期字符串为 datetime。
|
||
|
||
格式示例:
|
||
- "今天 09:02" → 今天的 09:02
|
||
- "昨天 23:29" → 昨天的 23:29
|
||
- "星期一 23:29" → 本周星期一的 23:29
|
||
- "2026-06-20" → 直接解析
|
||
- "06-20" → 今年的该日期
|
||
"""
|
||
now = datetime.now()
|
||
|
||
# 今天
|
||
if date_str.startswith("今天"):
|
||
time_str = date_str.replace("今天", "").strip()
|
||
try:
|
||
h, m = time_str.split(":")
|
||
return now.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
|
||
except (ValueError, AttributeError):
|
||
return None
|
||
|
||
# 昨天
|
||
if date_str.startswith("昨天"):
|
||
time_str = date_str.replace("昨天", "").strip()
|
||
try:
|
||
h, m = time_str.split(":")
|
||
dt = now - timedelta(days=1)
|
||
return dt.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
|
||
except (ValueError, AttributeError):
|
||
return None
|
||
|
||
# 星期X
|
||
weekday_map = {
|
||
"星期一": 0, "星期二": 1, "星期三": 2, "星期四": 3,
|
||
"星期五": 4, "星期六": 5, "星期日": 6, "星期天": 6,
|
||
}
|
||
for prefix, wd in weekday_map.items():
|
||
if date_str.startswith(prefix):
|
||
time_str = date_str.replace(prefix, "").strip()
|
||
try:
|
||
h, m = time_str.split(":")
|
||
# 计算本周对应日期
|
||
days_ago = (now.weekday() - wd) % 7
|
||
if days_ago == 0:
|
||
# 同一天但可能是上一周
|
||
target = now
|
||
else:
|
||
target = now - timedelta(days=days_ago)
|
||
return target.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
|
||
except (ValueError, AttributeError):
|
||
return None
|
||
|
||
# 日期格式 2026-06-20
|
||
try:
|
||
return datetime.strptime(date_str.strip(), "%Y-%m-%d")
|
||
except ValueError:
|
||
pass
|
||
|
||
# 日期格式 06-20
|
||
try:
|
||
dt = datetime.strptime(date_str.strip(), "%m-%d")
|
||
return dt.replace(year=now.year)
|
||
except ValueError:
|
||
pass
|
||
|
||
return None
|
||
|
||
# ── 主入口 ─────────────────────────────────────────────
|
||
|
||
def get_verification_code(
|
||
self,
|
||
max_wait: int = 60,
|
||
interval: int = 3,
|
||
after_timestamp: Optional[float] = None,
|
||
allow_old_seconds: int = 15,
|
||
stop_event: Optional[threading.Event] = None,
|
||
) -> str:
|
||
"""
|
||
轮询获取斗鱼验证码。
|
||
|
||
Args:
|
||
max_wait: 最大等待时间(秒)
|
||
interval: 轮询间隔(秒)
|
||
after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件
|
||
allow_old_seconds: 允许的时间偏移(秒)
|
||
stop_event: 外部停止信号,触发后尽快中断等待
|
||
|
||
Returns:
|
||
6位验证码
|
||
"""
|
||
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
|
||
|
||
deadline = time.monotonic() + max_wait
|
||
last_error = ""
|
||
|
||
while time.monotonic() < deadline:
|
||
if stop_event and stop_event.is_set():
|
||
raise InterruptedError("任务已停止")
|
||
|
||
try:
|
||
code = self._fetch_code_via_roundcube(after_timestamp, allow_old_seconds)
|
||
if code:
|
||
logger.success(f"获取到验证码: {code}")
|
||
return code
|
||
except Exception as e:
|
||
last_error = str(e)
|
||
logger.warning(f"Roundcube 读邮件异常: {e}")
|
||
|
||
logger.debug("未找到验证码,等待中...")
|
||
sleep_deadline = time.monotonic() + interval
|
||
while time.monotonic() < sleep_deadline:
|
||
if stop_event and stop_event.is_set():
|
||
raise InterruptedError("任务已停止")
|
||
time.sleep(min(0.2, sleep_deadline - time.monotonic()))
|
||
|
||
raise TimeoutError(f"等待验证码超时{'(' + last_error + ')' if last_error else ''}")
|
||
|
||
# ── Roundcube 方式获取验证码 ────────────────────────────
|
||
|
||
def _fetch_code_via_roundcube(
|
||
self,
|
||
after_timestamp: Optional[float] = None,
|
||
allow_old_seconds: int = 15,
|
||
) -> Optional[str]:
|
||
"""通过 Roundcube API 获取最新斗鱼验证码"""
|
||
messages = self._rc_fetch_mail_list()
|
||
|
||
# 筛选斗鱼验证码邮件(按 UID 降序,即最新的先看)
|
||
douyu_msgs = [
|
||
msg for msg in messages
|
||
if "斗鱼" in msg.get("subject", "") or "验证码" in msg.get("subject", "")
|
||
]
|
||
|
||
for msg in reversed(douyu_msgs): # UID 最大的先看
|
||
# 检查时间
|
||
if after_timestamp and msg.get("date"):
|
||
msg_dt = self._parse_rc_date(msg["date"])
|
||
if msg_dt:
|
||
# Roundcube 日期只有分钟精度(如"今天 14:20"→14:20:00),
|
||
# 而 after_timestamp 是秒级精度(如14:20:49)。
|
||
# 直接比较会把同一分钟内的邮件误判为"早于发送时间"跳过。
|
||
# 解决:将 after_timestamp 也向下取整到分钟后再比较。
|
||
after_minute_ts = after_timestamp - (after_timestamp % 60)
|
||
if msg_dt.timestamp() < after_minute_ts - allow_old_seconds:
|
||
logger.debug(f"Roundcube: 邮件 UID={msg['uid']} 日期 {msg['date']} 早于发送时间,跳过")
|
||
continue
|
||
|
||
# 读取邮件正文提取验证码
|
||
body_html = self._rc_fetch_email_body(msg["uid"])
|
||
if not body_html:
|
||
continue
|
||
|
||
code = self._extract_verification_code(body_html)
|
||
if code:
|
||
logger.info(f"Roundcube: 从邮件 UID={msg['uid']} 提取到验证码: {code}")
|
||
return code
|
||
|
||
return None
|
||
|
||
# ── 通用工具方法 ───────────────────────────────────────
|
||
|
||
def _extract_verification_code(self, text: str) -> Optional[str]:
|
||
"""从文本中提取6位验证码"""
|
||
# 清理HTML标签和实体
|
||
text = html.unescape(text)
|
||
text = re.sub(r'<[^>]+>', ' ', text)
|
||
text = re.sub(r'\s+', ' ', text)
|
||
|
||
# 查找6位数字验证码(优先匹配带上下文的)
|
||
patterns = [
|
||
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|:)?\s*(\d{6})',
|
||
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',
|
||
r'(\d{6})\s*(?:是|为)?(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)',
|
||
r'(?:verification\s+code|security\s+code|code)\s*(?:is|:|:)?\s*(\d{6})',
|
||
r'\b(\d{6})\b',
|
||
]
|
||
|
||
for pattern in patterns:
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
|
||
return None
|
||
|
||
|
||
def get_email_config_for_account(email_address: str) -> dict:
|
||
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
|
||
return {
|
||
'server': '',
|
||
'port': 143,
|
||
'ssl': False,
|
||
}
|