fix: 改用Roundcube API获取验证码,修复邮件时间解析不可靠问题

- 新增Roundcube Webmail登录+JSON API读取邮件列表(含精确日期)
- Roundcube优先,read.php降为备用回退
- 解析Roundcube返回的中文日期格式(今天/昨天/星期X HH:MM)
- 修复read.php时间字段被MIME编码Subject污染导致'保守拒绝'误判
- 修复邮件时间UTC/本地时区8小时偏差导致正常邮件被跳过的问题
- read.php备用模式下解析不到时间不再保守拒绝,改为放行
This commit is contained in:
yml2213
2026-06-23 09:34:44 +08:00
parent 3a065dfd98
commit 3b36175bbb
+301 -37
View File
@@ -1,19 +1,24 @@
"""邮箱验证模块 - 通过 HTTP 接口获取验证码(替代 IMAP)"""
"""邮箱验证模块 - 优先通过 Roundcube Webmail API 获取验证码,read.php 作为备用"""
import html
import re
import time
from datetime import datetime, timedelta
from typing import Optional
from loguru import logger
import requests
# HTTP 读邮件接口地址
MAIL_API_URL = "http://111.229.206.54:8000/read.php"
# 旧接口(时间解析不可靠,作为备用)
READ_PHP_URL = "http://111.229.206.54:8000/read.php"
# Roundcube Webmail 地址
ROUNDCUBE_URL = "http://111.229.206.54:8000/"
class EmailVerifier:
"""邮箱验证器(HTTP 方式"""
"""邮箱验证器(Roundcube API 优先,read.php 备用"""
def __init__(
self,
@@ -26,24 +31,229 @@ class EmailVerifier:
lookback_minutes: int = 10,
max_messages: int = 20,
use_ssl: bool = False,
mail_api_url: str = MAIL_API_URL,
mail_api_url: str = READ_PHP_URL,
):
# 兼容旧参数名HTTP 模式下 imap_* 参数仅用于日志
# 兼容旧参数名
self.imap_server = imap_server
self.imap_port = imap_port
self.username = username # 邮箱地址
self.password = password # 邮箱密码
self.timeout = timeout
self.mail_api_url = mail_api_url
self.use_ssl = use_ssl
self.read_php_url = mail_api_url or READ_PHP_URL
self.roundcube_url = 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:
"""兼容接口HTTP 模式下无需持久连接"""
"""兼容接口"""
pass
def disconnect(self) -> None:
"""兼容接口"""
pass
"""关闭 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,
@@ -53,7 +263,7 @@ class EmailVerifier:
allow_old_seconds: int = 15,
) -> str:
"""
轮询 HTTP 读邮件接口获取斗鱼验证码。
轮询获取斗鱼验证码。优先使用 Roundcube API,失败则回退到 read.php
Args:
max_wait: 最大等待时间(秒)
@@ -68,30 +278,83 @@ class EmailVerifier:
deadline = time.monotonic() + max_wait
last_error = ""
tried_roundcube = False
while time.monotonic() < deadline:
# 优先尝试 Roundcube
if not tried_roundcube or self._rc_logged_in:
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}")
tried_roundcube = True
# 回退到 read.php
try:
code = self._fetch_code_via_http(after_timestamp, allow_old_seconds)
code = self._fetch_code_via_readphp(after_timestamp, allow_old_seconds)
if code:
logger.success(f"获取到验证码: {code}")
return code
logger.debug("未找到验证码,等待中...")
except Exception as e:
last_error = str(e)
logger.warning(f"邮件异常: {e}")
logger.warning(f"read.php 读邮件异常: {e}")
logger.debug("未找到验证码,等待中...")
time.sleep(interval)
raise TimeoutError(f"等待验证码超时{'' + last_error + '' if last_error else ''}")
def _fetch_code_via_http(
# ── Roundcube 方式获取验证码 ────────────────────────────
def _fetch_code_via_roundcube(
self,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> Optional[str]:
"""通过 HTTP 接口读取最新邮件并提取验证码"""
"""通过 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 and msg_dt.timestamp() < after_timestamp - 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
# ── read.php 方式获取验证码(备用) ─────────────────────
def _fetch_code_via_readphp(
self,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> Optional[str]:
"""通过 read.php HTTP 接口读取最新邮件并提取验证码"""
try:
response = requests.get(
self.mail_api_url,
self.read_php_url,
params={
"yhm": self.username,
"mm": self.password,
@@ -103,26 +366,28 @@ class EmailVerifier:
# 检查是否是斗鱼验证码邮件
if not self._is_douyu_email(content):
logger.debug("最新邮件不是斗鱼验证码邮件")
logger.debug("read.php: 最新邮件不是斗鱼验证码邮件")
return None
# 检查邮件时间是否足够新(发送验证码请求之后
# 检查邮件时间(read.php 时间不可靠,放宽判断
if after_timestamp and not self._is_email_recent(content, after_timestamp, allow_old_seconds):
logger.debug("邮件时间早于验证码发送时间,跳过")
logger.debug("read.php: 邮件时间早于验证码发送时间,跳过")
return None
# 提取验证码
code = self._extract_verification_code(content)
if code:
logger.info(f"从邮件中提取到验证码: {code}")
logger.info(f"read.php: 从邮件中提取到验证码: {code}")
return code
return None
except requests.RequestException as e:
logger.warning(f"HTTP读邮件请求失败: {e}")
logger.warning(f"read.php: 请求失败: {e}")
return None
# ── 通用工具方法 ───────────────────────────────────────
def _is_douyu_email(self, content: str) -> bool:
"""判断邮件内容是否是斗鱼验证码邮件"""
douyu_keywords = ['斗鱼', 'douyu', 'douyutv']
@@ -141,10 +406,8 @@ class EmailVerifier:
allow_old_seconds: int = 15,
) -> bool:
"""
检查邮件时间是否晚于验证码发送时间。
HTTP 接口返回的 HTML 中包含 "发信时间: Tue, 23 Jun 2026 01:06:09 +0800"
但可能被 HTML 标签包裹,如 "</label> Tue, 23 Jun 2026 01:06:09 +0800"
检查 read.php 返回的邮件时间是否晚于验证码发送时间。
注意:read.php 的时间解析不可靠,此处仅作参考判断。
"""
from email.utils import parsedate_to_datetime
@@ -152,7 +415,7 @@ class EmailVerifier:
clean = re.sub(r'<[^>]+>', ' ', content)
clean = re.sub(r'\s+', ' ', clean)
# 匹配 RFC2822 格式时间Day, DD Mon YYYY HH:MM:SS ±HHMM
# 匹配 RFC2822 格式时间
time_match = re.search(
r'发信时间[:]?\s*'
r'((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*\d{1,2}\s+'
@@ -161,7 +424,7 @@ class EmailVerifier:
clean,
)
if not time_match:
# 尝试匹配不带星期的时间格式2026-06-23 01:31:38
# 尝试匹配不带星期的时间格式
time_match2 = re.search(
r'发信时间[:]?\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})',
clean,
@@ -169,17 +432,18 @@ class EmailVerifier:
if time_match2:
time_str = time_match2.group(1).strip()
try:
from datetime import datetime
msg_time = datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
from datetime import datetime as dt
msg_time = dt.strptime(time_str, '%Y-%m-%d %H:%M:%S')
if msg_time.timestamp() < after_timestamp - allow_old_seconds:
logger.debug(f"邮件时间 {time_str} 早于发送时间,跳过旧邮件")
return False
return True
except ValueError:
pass
# 找不到时间且无法解析,保守拒绝(避免使用旧验证码)
logger.debug("邮件中未找到可解析的发信时间,保守拒绝")
return False
# read.php 时间不可靠时,不再保守拒绝,改为放行
# 让验证码提取逻辑自行判断
logger.debug("read.php: 未找到可解析的发信时间,跳过时间检查直接提取验证码")
return True
time_str = time_match.group(1).strip()
try:
@@ -189,8 +453,8 @@ class EmailVerifier:
return False
return True
except (TypeError, ValueError):
logger.debug(f"无法解析邮件时间: {time_str}保守拒绝")
return False
logger.debug(f"无法解析邮件时间: {time_str}跳过时间检查")
return True
def _extract_verification_code(self, text: str) -> Optional[str]:
"""从文本中提取6位验证码"""
@@ -199,7 +463,7 @@ class EmailVerifier:
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text)
# 查找6位数字验证码
# 查找6位数字验证码(优先匹配带上下文的)
patterns = [
r'(?:验证码|校验码|动态码|安全码)\s*(?:是|为|:|)?\s*(\d{6})',
r'(?:您的|你的|本次)?(?:验证码|校验码|动态码|安全码)\s*(?:是|为)?\s*(\d{6})',