邮箱验证改用HTTP接口替代IMAP

- 通过 http://111.229.206.54:8000/read.php 读取邮件,不再依赖IMAP
- 解决IMAP密码错误/授权码过期导致45%登录失败的问题
- 错误密码不再抛异常,返回None由上层处理
- 兼容原有EmailVerifier接口签名,login.py无需改动
This commit is contained in:
yml2213
2026-06-23 01:25:38 +08:00
parent 021b96628e
commit d47b8e24fa
+102 -286
View File
@@ -1,381 +1,197 @@
"""邮箱验证模块 - IMAP获取验证码"""
"""邮箱验证模块 - 通过 HTTP 接口获取验证码(替代 IMAP)"""
import imaplib
import email
import html
import re
import time
from datetime import datetime, timedelta
from email.header import decode_header
from email.utils import parsedate_to_datetime
from typing import Optional
from loguru import logger
import requests
# HTTP 读邮件接口地址
MAIL_API_URL = "http://111.229.206.54:8000/read.php"
class EmailVerifier:
"""邮箱验证器"""
"""邮箱验证器HTTP 方式)"""
def __init__(
self,
imap_server: str,
imap_port: int,
username: str,
password: str,
timeout: float = 12,
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 = True,
use_ssl: bool = False,
mail_api_url: str = MAIL_API_URL,
):
# 兼容旧参数名,HTTP 模式下 imap_* 参数仅用于日志
self.imap_server = imap_server
self.imap_port = imap_port
self.username = username
self.password = password
self.username = username # 邮箱地址
self.password = password # 邮箱密码
self.timeout = timeout
self.mailbox = mailbox
self.lookback_minutes = lookback_minutes
self.max_messages = max_messages
self.mail_api_url = mail_api_url
self.use_ssl = use_ssl
self._connection: Optional[imaplib.IMAP4_SSL] = None
def connect(self) -> None:
"""连接IMAP服务器"""
try:
logger.info(f"连接IMAP服务器: {self.imap_server}:{self.imap_port} (SSL={self.use_ssl})")
self._validate_login_text()
if self.use_ssl:
self._connection = imaplib.IMAP4_SSL(
self.imap_server,
self.imap_port,
timeout=self.timeout,
)
else:
self._connection = imaplib.IMAP4(
self.imap_server,
self.imap_port,
timeout=self.timeout,
)
self._connection.login(self.username, self.password)
logger.info("IMAP连接成功")
except Exception as e:
logger.error(f"IMAP连接失败: {e}")
raise
def _validate_login_text(self) -> None:
"""提前检查IMAP登录字段,给出比ascii编码异常更明确的提示。"""
for label, value in (("邮箱账号", self.username), ("邮箱密码/授权码", self.password)):
try:
value.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError(
f"{label}包含中文或其他非ASCII字符,IMAP无法登录;"
"请检查导入格式是否为:用户名|密码|邮箱|邮箱密码"
) from exc
"""兼容接口:HTTP 模式下无需持久连接"""
pass
def disconnect(self) -> None:
"""断开IMAP连接"""
if self._connection:
try:
self._connection.logout()
except:
pass
self._connection = None
"""兼容接口"""
pass
def get_verification_code(
self,
max_wait: int = 60,
interval: int = 2,
interval: int = 3,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> str:
"""
获取斗鱼验证码
轮询 HTTP 读邮件接口获取斗鱼验证码
Args:
max_wait: 最大等待时间(秒)
interval: 轮询间隔(秒)
after_timestamp: 发起发送验证码请求的时间戳,用于过滤旧邮件
allow_old_seconds: 邮件服务器时间允许向前偏移的秒数
after_timestamp: 发送验证码请求的时间戳,用于过滤旧邮件
allow_old_seconds: 允许的时间偏移(秒)
Returns:
6位验证码
"""
logger.info(f"等待斗鱼验证码邮件,最大等待 {max_wait} 秒...")
self.connect()
deadline = time.monotonic() + max_wait
last_error = ""
try:
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
code = self._fetch_latest_code(
after_timestamp=after_timestamp,
allow_old_seconds=allow_old_seconds,
)
while time.monotonic() < deadline:
try:
code = self._fetch_code_via_http(after_timestamp, allow_old_seconds)
if code:
logger.success(f"获取到验证码: {code}")
return code
logger.debug("未找到验证码,等待中...")
time.sleep(interval)
except Exception as e:
last_error = str(e)
logger.warning(f"读取邮件异常: {e}")
time.sleep(interval)
raise TimeoutError("等待验证码超时")
finally:
self.disconnect()
raise TimeoutError(f"等待验证码超时{'' + last_error + '' if last_error else ''}")
def _fetch_latest_code(
def _fetch_code_via_http(
self,
after_timestamp: Optional[float] = None,
allow_old_seconds: int = 15,
) -> Optional[str]:
"""从IMAP获取最新验证码"""
"""通过 HTTP 接口读取最新邮件并提取验证码"""
try:
if not self._connection:
raise RuntimeError("IMAP未连接")
status, _ = self._connection.select(self.mailbox, readonly=True)
if status != 'OK':
logger.warning(f"选择邮箱目录失败: {self.mailbox}")
return None
since = self._build_since_date(after_timestamp, allow_old_seconds)
# IMAP命令只能稳定发送ASCII条件,中文主题/发件人改到本地解析过滤。
status, messages = self._connection.search(None, 'SINCE', since)
if status != 'OK' or not messages[0]:
return None
message_ids = messages[0].split()
recent_ids = list(reversed(message_ids[-self.max_messages:]))
logger.debug(f"扫描最近 {len(recent_ids)} 封邮件,SINCE {since}")
for message_id in recent_ids:
msg = self._fetch_message(message_id)
if not msg:
continue
if not self._is_recent_enough(msg, after_timestamp, allow_old_seconds):
continue
subject = self._decode_subject(msg.get('Subject', ''))
from_addr = msg.get('From', '')
to_addr = msg.get('To', '') or msg.get('Delivered-To', '') or ''
body = self._get_email_body(msg)
if not self._is_douyu_email(subject, from_addr, body, to_addr=to_addr):
continue
code = self._extract_verification_code(body)
if code:
logger.info(f"从邮件中提取到验证码,主题: {subject}")
return code
return None
except imaplib.IMAP4.abort as e:
logger.warning(f"IMAP连接中断,准备下轮重连: {e}")
self.disconnect()
self.connect()
return None
except Exception as e:
logger.error(f"获取邮件失败: {e}")
return None
def _build_since_date(
self,
after_timestamp: Optional[float],
allow_old_seconds: int,
) -> str:
"""构造IMAP SINCE日期,月份固定用英文缩写。"""
if after_timestamp:
since_dt = datetime.fromtimestamp(
max(0, after_timestamp - allow_old_seconds)
response = requests.get(
self.mail_api_url,
params={
"yhm": self.username,
"mm": self.password,
},
timeout=self.timeout,
)
else:
since_dt = datetime.now() - timedelta(minutes=self.lookback_minutes)
response.raise_for_status()
content = response.text
return self._format_imap_date(since_dt)
# 检查是否是斗鱼验证码邮件
if not self._is_douyu_email(content):
logger.debug("最新邮件不是斗鱼验证码邮件")
return None
def _format_imap_date(self, value: datetime) -> str:
"""格式化IMAP日期,避免系统locale影响月份名称。"""
months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
return f"{value.day:02d}-{months[value.month - 1]}-{value.year}"
# 检查邮件时间是否足够新(发送验证码请求之后)
if after_timestamp and not self._is_email_recent(content, after_timestamp, allow_old_seconds):
logger.debug("邮件时间早于验证码发送时间,跳过")
return None
# 提取验证码
code = self._extract_verification_code(content)
if code:
logger.info(f"从邮件中提取到验证码: {code}")
return code
def _fetch_message(self, message_id: bytes) -> Optional[email.message.Message]:
"""获取邮件完整内容,使用PEEK避免标记已读。"""
status, msg_data = self._connection.fetch(message_id, '(BODY.PEEK[])')
if status != 'OK':
return None
raw_message = self._join_fetch_payload(msg_data)
if not raw_message:
except requests.RequestException as e:
logger.warning(f"HTTP读邮件请求失败: {e}")
return None
return email.message_from_bytes(raw_message)
def _join_fetch_payload(self, msg_data) -> bytes:
"""合并IMAP fetch返回中的邮件字节内容。"""
chunks = []
for item in msg_data:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes):
chunks.append(item[1])
elif isinstance(item, bytes) and item.startswith(b'From '):
chunks.append(item)
return b''.join(chunks)
def _is_recent_enough(
self,
msg: email.message.Message,
after_timestamp: Optional[float],
allow_old_seconds: int,
) -> bool:
"""检查邮件时间是否晚于本次发送验证码请求。"""
if not after_timestamp:
return True
message_time = self._parse_message_time(msg)
if not message_time:
logger.debug("邮件缺少Date头,保守纳入候选")
return True
if message_time.timestamp() < after_timestamp - allow_old_seconds:
subject = self._decode_subject(msg.get('Subject', ''))
logger.debug(f"跳过旧邮件: {subject}")
return False
return True
def _parse_message_time(self, msg: email.message.Message) -> Optional[datetime]:
"""解析邮件Date头。"""
date_header = msg.get('Date')
if not date_header:
return None
try:
return parsedate_to_datetime(date_header)
except (TypeError, ValueError):
return None
def _is_douyu_email(self, subject: str, from_addr: str, body: str = "", to_addr: str = "") -> bool:
"""判断是否是发给当前账号的斗鱼验证码邮件"""
# 先检查收件人:多个账号共用同一个IMAP时,必须匹配收件人地址
if to_addr and self.username:
# to_addr 可能是 "name <email>" 格式,提取邮箱
to_email = re.search(r'[\w.+-]+@[\w.-]+', to_addr)
if to_email:
to_lower = to_email.group(0).lower()
my_lower = self.username.lower()
# 精确匹配,或者 catch-all 前缀匹配
if to_lower != my_lower and not my_lower.endswith('@' + to_lower.split('@')[-1]):
logger.debug(f"跳过非当前账号邮件: 收件人={to_lower}, 当前={my_lower}")
return False
def _is_douyu_email(self, content: str) -> bool:
"""判断邮件内容是否是斗鱼验证码邮件"""
douyu_keywords = ['斗鱼', 'douyu', 'douyutv']
verify_keywords = ['验证码', '安全验证', '登录验证', '动态码', '校验码']
combined = f"{subject}\n{from_addr}\n{body[:500]}".lower()
combined = content.lower()
return (
any(keyword in combined for keyword in douyu_keywords)
or any(keyword in subject for keyword in verify_keywords)
or any(keyword in content for keyword in verify_keywords)
)
def _decode_subject(self, subject: str) -> str:
"""解码邮件主题"""
if not subject:
return ""
def _is_email_recent(
self,
content: str,
after_timestamp: float,
allow_old_seconds: int = 15,
) -> bool:
"""
检查邮件时间是否晚于验证码发送时间。
decoded_parts = decode_header(subject)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or 'utf-8', errors='ignore'))
else:
result.append(part)
return ' '.join(result)
HTTP 接口返回的 HTML 中包含 "发信时间: Tue, 23 Jun 2026 01:06:09 +0800"
"""
# 提取发信时间
from email.utils import parsedate_to_datetime
time_match = re.search(r'发信时间:\s*(.+?)[\r\n<]', content)
if not time_match:
# 找不到时间,保守认为足够新
logger.debug("邮件中未找到发信时间,保守纳入候选")
return True
def _get_email_body(self, msg: email.message.Message) -> str:
"""获取邮件正文"""
parts = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if part.get_content_disposition() == 'attachment':
continue
if content_type == 'text/plain' or content_type == 'text/html':
decoded = self._decode_part_payload(part)
if decoded:
parts.append(decoded)
else:
decoded = self._decode_part_payload(msg)
if decoded:
parts.append(decoded)
return "\n".join(parts)
def _decode_part_payload(self, part: email.message.Message) -> str:
"""解码邮件片段内容。"""
time_str = time_match.group(1).strip()
try:
payload = part.get_payload(decode=True)
if payload is None:
raw_payload = part.get_payload()
return raw_payload if isinstance(raw_payload, str) else ""
charset = part.get_content_charset() or 'utf-8'
return payload.decode(charset, errors='ignore')
except Exception:
return ""
from datetime import datetime
msg_time = parsedate_to_datetime(time_str)
if msg_time and msg_time.timestamp() < after_timestamp - allow_old_seconds:
return False
return True
except (TypeError, ValueError):
logger.debug(f"无法解析邮件时间: {time_str},保守纳入候选")
return True
def _extract_verification_code(self, text: str) -> Optional[str]:
"""从文本中提取验证码"""
# 清理HTML标签和实体,方便匹配中文邮件模板。
"""从文本中提取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', # 候选邮件已经过滤为斗鱼验证码邮件,最后再兜底匹配6位数字。
r'\b(\d{6})\b',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
code = match.group(1)
return code
return match.group(1)
return None
def get_email_config_for_account(email_address: str) -> dict:
"""根据邮箱地址自动配置IMAP服务器,默认使用统一IMAP代理"""
configs = {
'qq.com': {'server': 'imap.qq.com', 'port': 993, 'ssl': True},
'163.com': {'server': 'imap.163.com', 'port': 993, 'ssl': True},
'126.com': {'server': 'imap.126.com', 'port': 993, 'ssl': True},
'gmail.com': {'server': 'imap.gmail.com', 'port': 993, 'ssl': True},
'outlook.com': {'server': 'outlook.office365.com', 'port': 993, 'ssl': True},
'hotmail.com': {'server': 'outlook.office365.com', 'port': 993, 'ssl': True},
"""根据邮箱地址返回配置(HTTP 模式下仅用于兼容)"""
return {
'server': '',
'port': 143,
'ssl': False,
}
domain = email_address.split('@')[-1].lower()
configs = {
'qq.com': {'server': 'imap.qq.com', 'port': 993, 'ssl': True},
'163.com': {'server': 'imap.163.com', 'port': 993, 'ssl': True},
'126.com': {'server': 'imap.126.com', 'port': 993, 'ssl': True},
'gmail.com': {'server': 'imap.gmail.com', 'port': 993, 'ssl': True},
'outlook.com': {'server': 'outlook.office365.com', 'port': 993, 'ssl': True},
'hotmail.com': {'server': 'outlook.office365.com', 'port': 993, 'ssl': True},
'bdhg.xyz': {'server': 'mail.bdhg.xyz', 'port': 143, 'ssl': False},
}
domain = email_address.split('@')[-1].lower()
return configs.get(domain, {'server': domain, 'port': 143, 'ssl': False})