Files
live-hub-py/core/douyu/email_verifier.py
T
yml2213 4d87cdc16d fix(douyu): 取邮箱验证码取错邮件,导致登录永远报「邮箱验证码错误」
线上现象:同一个邮箱每次登录都取到同一个旧验证码(UID=1、几小时前那封),
提交必然失败;整体重试 20 次又把斗鱼的发信配额打爆。

两个根因:

1. Roundcube 返回的邮件列表本来就是 UID 降序(最新在前),代码又 reversed()
   了一次,于是从收件箱最旧的一封开始读;
2. _parse_rc_date 用 UTC 作为基准,而 Roundcube 显示的是邮箱本地时间,旧邮件
   被凭空加了 8 小时,看起来比发送时间还新,时间过滤形同虚设。

改动:

- 发信前用 latest_douyu_uid() 记录收件箱最新斗鱼邮件 UID,之后只接受 UID
  更大的邮件,精确锁定「本次发送触发的那封」,不再依赖列表顺序和日期解析;
- 明确按 UID 降序读取,去掉多余的 reversed();
- 日期解析改为本地时区 aware datetime,时间兜底过滤才真正生效;
- 斗鱼返回「邮箱验证码发送次数已达上限」时抛 EmailQuotaError,直接终止批次
  不再重试,避免继续烧配额。

线上实测(忧伤的心750 / aakpfc@bdhg.xyz):修复前每次读 UID=1 的旧码
942715;修复后基线 UID=20、按发送时间与快照过滤均正确跳过旧邮件,正文读取
取到最新一封。
2026-10-01 18:42:56 +08:00

545 lines
21 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.
"""邮箱验证模块 - 通过 Roundcube Webmail API 获取验证码。"""
import html
import os
import re
import threading
import time
from datetime import UTC, datetime, timedelta
import requests
from loguru import logger
# Roundcube Webmail 地址。可通过环境变量覆盖。空值时回退到默认。
ROUNDCUBE_URL = os.getenv("MAIL_ROUNDCUBE_URL") or "http://111.229.206.54:8000/"
EMAIL_BACKUP_PASSWORDS = [
pwd.strip()
for pwd in os.getenv("MAIL_BACKUP_PASSWORDS", "aa778899").split(";")
if pwd.strip()
]
class EmailLoginError(RuntimeError):
"""邮箱登录失败,通常是邮箱账号或密码错误。"""
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 = "",
backup_passwords: list[str] | None = None,
):
# 兼容旧参数名
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
self.backup_passwords = (
backup_passwords if backup_passwords is not None else EMAIL_BACKUP_PASSWORDS
)
# Roundcube 会话(懒初始化)
self._rc_session: requests.Session | None = None
self._rc_token: str | None = None
self._rc_logged_in = False
self._rc_login_error = ""
def connect(self) -> None:
"""兼容接口"""
def disconnect(self) -> None:
"""关闭 Roundcube 会话"""
if self._rc_session:
try:
self._rc_session.get(
f"{self.roundcube_url}?_task=logout",
timeout=5,
)
except Exception as exc: # noqa: BLE001
logger.debug(f"Roundcube logout failed: {exc}")
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
self._rc_login_error = ""
credential_failed = False
passwords = self._candidate_passwords()
for index, password in enumerate(passwords):
is_backup = index > 0
ok, password_failed = self._try_roundcube_login(
password, is_backup=is_backup
)
if ok:
return True
credential_failed = credential_failed or password_failed
if password_failed and index == 0 and len(passwords) > 1:
logger.info("Roundcube: 主邮箱密码失败,尝试备用邮箱密码")
if credential_failed:
self._rc_login_error = "邮箱登录失败"
logger.warning(f"Roundcube: {self._rc_login_error}")
return False
def _candidate_passwords(self) -> list[str]:
"""返回去重后的邮箱密码候选列表:主密码优先,其次备用密码。"""
candidates = [self.password, *self.backup_passwords]
result = []
seen = set()
for password in candidates:
if not password or password in seen:
continue
result.append(password)
seen.add(password)
return result
def _try_roundcube_login(
self, password: str, is_backup: bool = False
) -> tuple[bool, bool]:
"""尝试一次 Roundcube 登录,返回 (是否成功, 是否明确为密码错误)。"""
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, 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": password,
},
timeout=self.timeout,
allow_redirects=True,
)
# 3. 检查登录是否成功:登录失败页也可能有 request_token,不能只靠 token 判定。
new_token_match = re.search(
r'request_token["\s:]+["\']([^"\']+)', resp.text
)
if not new_token_match:
logger.warning("Roundcube: 登录失败(未找到 request_token)")
return False, False
text_lower = resp.text.lower()
login_failed = "_err=loginfailed" in resp.url or "loginfailed" in text_lower
login_form_present = (
'name="_user"' in resp.text and 'name="_pass"' in resp.text
)
mail_page = "_task=mail" in resp.url or re.search(
r'(?:env\.task\s*=\s*|["\']task["\']\s*:\s*)["\']mail["\']', resp.text
)
if login_failed or login_form_present or not mail_page:
password_name = "备用邮箱密码" if is_backup else "主邮箱密码"
logger.warning(f"Roundcube: {password_name}登录失败")
return False, True
self._rc_session = session
self._rc_token = new_token_match.group(1)
self._rc_logged_in = True
password_name = "备用邮箱密码" if is_backup else "主邮箱密码"
logger.debug(f"Roundcube: {password_name}登录成功 ({self.username})")
return True, False
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 登录异常: {e}")
return False, 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():
if self._rc_login_error:
raise EmailLoginError(self._rc_login_error)
return []
if self._rc_session is None:
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: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 获取邮件列表失败: {e}")
return []
# ── Roundcube 读取邮件内容 ─────────────────────────────
def _rc_fetch_email_body(self, uid: int) -> str | None:
"""通过 Roundcube 读取指定 UID 邮件的正文"""
if not self._ensure_roundcube_session():
if self._rc_login_error:
raise EmailLoginError(self._rc_login_error)
return None
if self._rc_session is None:
return None
try:
resp = self._rc_session.get(
f"{self.roundcube_url}?_task=mail&_action=show&_mbox=INBOX&_uid={uid}",
timeout=self.timeout,
)
return resp.text
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 读取邮件 {uid} 失败: {e}")
return None
# ── Roundcube 日期解析 ─────────────────────────────────
@staticmethod
def _parse_rc_date(date_str: str) -> datetime | None:
"""
解析 Roundcube 返回的日期字符串为「本地时区」的 aware datetime。
Roundcube 显示的是邮箱服务器本地时间(如"今天 09:02"),因此基准必须
取本地时间;早期实现用 UTC 作为 now,会把 09:02 当成 UTC 09:02,
等于凭空多出 8 小时,导致旧邮件被误判成"比发送时间还新"而无法跳过。
格式示例:
- "今天 09:02" → 今天的 09:02
- "昨天 23:29" → 昨天的 23:29
- "星期一 23:29" → 本周星期一的 23:29
- "2026-06-20" → 直接解析
- "06-20" → 今年的该日期
"""
# now(tz=UTC).astimezone() 得到带本地时区信息的当前时间,时间戳换算才准确。
now = datetime.now(tz=UTC).astimezone()
local_tz = now.tzinfo
# 今天
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").replace(
tzinfo=local_tz
)
except ValueError:
pass
# 日期格式 2026-06-20 14:30
try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M").replace(
tzinfo=local_tz
)
except ValueError:
pass
# 日期格式 2026-06-20 14:30:00
try:
return datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S").replace(
tzinfo=local_tz
)
except ValueError:
pass
# 日期格式 06-20
try:
dt = datetime.strptime(date_str.strip(), "%m-%d").replace(tzinfo=local_tz)
return dt.replace(year=now.year)
except ValueError:
pass
# 日期格式 06-20 14:30
try:
dt = datetime.strptime(date_str.strip(), "%m-%d %H:%M").replace(
tzinfo=local_tz
)
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: float | None = None,
allow_old_seconds: int = 0,
stop_event: threading.Event | None = None,
min_uid: int = 0,
) -> str:
"""
轮询获取斗鱼验证码。
Args:
max_wait: 最大等待时间(秒)
interval: 轮询间隔(秒)
after_timestamp: 发送验证码请求的时间戳,min_uid 缺失时用于过滤旧邮件
allow_old_seconds: 允许的时间偏移(秒)
stop_event: 外部停止信号,触发后尽快中断等待
min_uid: 发信前收件箱最新斗鱼邮件的 UID,只接受比它更新的邮件
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, min_uid
)
if code:
logger.success(f"获取到验证码: {code}")
return code
except EmailLoginError:
raise
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
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 方式获取验证码 ────────────────────────────
@staticmethod
def _is_douyu_code_mail(msg: dict) -> bool:
"""判断是否斗鱼验证码邮件。"""
subject = msg.get("subject", "")
return "斗鱼" in subject or "验证码" in subject
def latest_douyu_uid(self) -> int:
"""返回当前收件箱里最新一封斗鱼验证码邮件的 UID,没有则 0。
发信前先取一次快照,之后只接受 UID 更大的邮件,就能精确锁定
「本次发送触发的那封」,不依赖邮件列表顺序或日期解析。
"""
try:
messages = self._rc_fetch_mail_list()
except Exception as e: # noqa: BLE001 外部接口边界需要保留宽泛异常兜底
logger.warning(f"Roundcube: 获取基线 UID 失败: {e}")
return 0
uids = [msg["uid"] for msg in messages if self._is_douyu_code_mail(msg)]
return max(uids) if uids else 0
def _fetch_code_via_roundcube(
self,
after_timestamp: float | None = None,
allow_old_seconds: int = 0,
min_uid: int = 0,
) -> str | None:
"""通过 Roundcube API 获取最新斗鱼验证码。
``min_uid`` 为发信前的快照 UID,只有比它更新的邮件才会被采纳;
缺失时才退回按发送时间过滤(Roundcube 日期只有分钟精度,容易被
上一轮的邮件蒙混过关)。
"""
messages = self._rc_fetch_mail_list()
# 明确按 UID 降序:Roundcube 返回的顺序不保证,之前用 reversed()
# 会把最新的邮件排到最后,结果每次都读到收件箱最旧的那封旧验证码。
douyu_msgs = sorted(
(msg for msg in messages if self._is_douyu_code_mail(msg)),
key=lambda msg: msg["uid"],
reverse=True,
)
for msg in douyu_msgs:
if min_uid and msg["uid"] <= min_uid:
logger.debug(
f"Roundcube: 邮件 UID={msg['uid']} 不晚于发信快照 {min_uid},跳过"
)
continue
# 没有快照时才用时间兜底
if not min_uid and after_timestamp and msg.get("date"):
msg_dt = self._parse_rc_date(msg["date"])
if msg_dt is None:
# 日期无法解析的旧邮件(如完整时间戳),在有 after_timestamp 时跳过
logger.debug(
f"Roundcube: 邮件 UID={msg['uid']} 日期 '{msg['date']}' 无法解析,已跳过"
)
continue
# 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) -> str | None:
"""从文本中提取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,
}