feat(services): 新增标准 IMAP 邮箱服务支持(Gmail/QQ/163等)

- 新增 EmailServiceType.IMAP_MAIL 枚举值和默认配置
- 新建 ImapMailService(imaplib 标准库,强制直连)
- 注册路由新增 imap_mail 分支和 available-services 键
- 邮箱服务路由新增 imap_mail stats 统计和类型描述
- accounts 路由 _build_inbox_config 支持 imap_mail
- 前端表单/列表/编辑完整支持 IMAP 子类型
- 无新增依赖
This commit is contained in:
cnlimiter
2026-03-20 15:37:27 +08:00
parent 9ada1f6ec6
commit 0059cf97bd
10 changed files with 399 additions and 12 deletions

View File

@@ -37,6 +37,7 @@ class EmailServiceType(str, Enum):
TEMP_MAIL = "temp_mail"
DUCK_MAIL = "duck_mail"
FREEMAIL = "freemail"
IMAP_MAIL = "imap_mail"
# ============================================================================
@@ -128,6 +129,15 @@ EMAIL_SERVICE_DEFAULTS = {
"domain": "",
"timeout": 30,
"max_retries": 3,
},
"imap_mail": {
"host": "",
"port": 993,
"use_ssl": True,
"email": "",
"password": "",
"timeout": 30,
"max_retries": 3,
}
}

View File

@@ -16,6 +16,7 @@ from .moe_mail import MeoMailEmailService
from .temp_mail import TempMailService
from .duck_mail import DuckMailService
from .freemail import FreemailService
from .imap_mail import ImapMailService
# 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
@@ -24,6 +25,7 @@ EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService)
EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
# 导出 Outlook 模块的额外内容
from .outlook.base import (
@@ -56,6 +58,7 @@ __all__ = [
'TempMailService',
'DuckMailService',
'FreemailService',
'ImapMailService',
# Outlook 模块
'ProviderType',
'EmailMessage',

217
src/services/imap_mail.py Normal file
View File

@@ -0,0 +1,217 @@
"""
IMAP 邮箱服务
支持 Gmail / QQ / 163 / Yahoo / Outlook 等标准 IMAP 协议邮件服务商。
仅用于接收验证码强制直连imaplib 不支持代理)。
"""
import imaplib
import email
import re
import time
import logging
from email.header import decode_header
from typing import Any, Dict, Optional
from .base import BaseEmailService, EmailServiceError
from ..config.constants import (
EmailServiceType,
OPENAI_EMAIL_SENDERS,
OTP_CODE_SEMANTIC_PATTERN,
OTP_CODE_PATTERN,
)
logger = logging.getLogger(__name__)
class ImapMailService(BaseEmailService):
"""标准 IMAP 邮箱服务(仅接收验证码,强制直连)"""
def __init__(self, config: Dict[str, Any] = None, name: str = None):
super().__init__(EmailServiceType.IMAP_MAIL, name)
cfg = config or {}
required_keys = ["host", "email", "password"]
missing_keys = [k for k in required_keys if not cfg.get(k)]
if missing_keys:
raise ValueError(f"缺少必需配置: {missing_keys}")
self.host: str = str(cfg["host"]).strip()
self.port: int = int(cfg.get("port", 993))
self.use_ssl: bool = bool(cfg.get("use_ssl", True))
self.email_addr: str = str(cfg["email"]).strip()
self.password: str = str(cfg["password"])
self.timeout: int = int(cfg.get("timeout", 30))
self.max_retries: int = int(cfg.get("max_retries", 3))
def _connect(self) -> imaplib.IMAP4:
"""建立 IMAP 连接并登录,返回 mail 对象"""
if self.use_ssl:
mail = imaplib.IMAP4_SSL(self.host, self.port)
else:
mail = imaplib.IMAP4(self.host, self.port)
mail.starttls()
mail.login(self.email_addr, self.password)
return mail
def _decode_str(self, value) -> str:
"""解码邮件头部字段"""
if value is None:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(str(part))
return " ".join(decoded)
def _get_text_body(self, msg) -> str:
"""提取邮件纯文本内容"""
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
charset = part.get_content_charset() or "utf-8"
payload = part.get_payload(decode=True)
if payload:
body += payload.decode(charset, errors="replace")
else:
charset = msg.get_content_charset() or "utf-8"
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors="replace")
return body
def _is_openai_sender(self, from_addr: str) -> bool:
"""判断发件人是否为 OpenAI"""
from_lower = from_addr.lower()
for sender in OPENAI_EMAIL_SENDERS:
if sender.startswith("@") or sender.startswith("."):
if sender in from_lower:
return True
else:
if sender in from_lower:
return True
return False
def _extract_otp(self, text: str) -> Optional[str]:
"""从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE)
if match:
return match.group(1)
match = re.search(OTP_CODE_PATTERN, text)
if match:
return match.group(1)
return None
def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
"""IMAP 模式不创建新邮箱,直接返回配置中的固定地址"""
self.update_status(True)
return {
"email": self.email_addr,
"service_id": self.email_addr,
"id": self.email_addr,
}
def get_verification_code(
self,
email: str,
email_id: str = None,
timeout: int = 60,
pattern: str = None,
otp_sent_at: Optional[float] = None,
) -> Optional[str]:
"""轮询 IMAP 收件箱,获取 OpenAI 验证码"""
start_time = time.time()
seen_ids: set = set()
mail = None
try:
mail = self._connect()
mail.select("INBOX")
while time.time() - start_time < timeout:
try:
# 搜索所有未读邮件
status, data = mail.search(None, "UNSEEN")
if status != "OK" or not data or not data[0]:
time.sleep(3)
continue
msg_ids = data[0].split()
for msg_id in reversed(msg_ids): # 最新的优先
id_str = msg_id.decode()
if id_str in seen_ids:
continue
seen_ids.add(id_str)
# 获取邮件
status, msg_data = mail.fetch(msg_id, "(RFC822)")
if status != "OK" or not msg_data:
continue
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
# 检查发件人
from_addr = self._decode_str(msg.get("From", ""))
if not self._is_openai_sender(from_addr):
continue
# 提取验证码
body = self._get_text_body(msg)
code = self._extract_otp(body)
if code:
# 标记已读
mail.store(msg_id, "+FLAGS", "\\Seen")
self.update_status(True)
logger.info(f"IMAP 获取验证码成功: {code}")
return code
except imaplib.IMAP4.error as e:
logger.debug(f"IMAP 搜索邮件失败: {e}")
# 尝试重新连接
try:
mail.select("INBOX")
except Exception:
pass
time.sleep(3)
except Exception as e:
logger.warning(f"IMAP 连接/轮询失败: {e}")
self.update_status(False, str(e))
finally:
if mail:
try:
mail.logout()
except Exception:
pass
return None
def check_health(self) -> bool:
"""尝试 IMAP 登录并选择收件箱"""
mail = None
try:
mail = self._connect()
status, _ = mail.select("INBOX")
return status == "OK"
except Exception as e:
logger.warning(f"IMAP 健康检查失败: {e}")
return False
finally:
if mail:
try:
mail.logout()
except Exception:
pass
def list_emails(self, **kwargs) -> list:
"""IMAP 单账号模式,返回固定地址"""
return [{"email": self.email_addr, "id": self.email_addr}]
def delete_email(self, email_id: str) -> bool:
"""IMAP 模式无需删除逻辑"""
return True

View File

@@ -1002,6 +1002,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
EST.TEMP_MAIL: "temp_mail",
EST.DUCK_MAIL: "duck_mail",
EST.FREEMAIL: "freemail",
EST.IMAP_MAIL: "imap_mail",
EST.OUTLOOK: "outlook",
}
db_type = type_map.get(service_type)

View File

@@ -146,6 +146,7 @@ async def get_email_services_stats():
'temp_mail_count': 0,
'duck_mail_count': 0,
'freemail_count': 0,
'imap_mail_count': 0,
'tempmail_available': True, # 临时邮箱始终可用
'enabled_count': enabled_count
}
@@ -161,6 +162,8 @@ async def get_email_services_stats():
stats['duck_mail_count'] = count
elif service_type == 'freemail':
stats['freemail_count'] = count
elif service_type == 'imap_mail':
stats['imap_mail_count'] = count
return stats
@@ -231,6 +234,18 @@ async def get_service_types():
{"name": "admin_token", "label": "Admin Token", "required": True, "secret": True},
{"name": "domain", "label": "邮箱域名", "required": False, "placeholder": "example.com"},
]
},
{
"value": "imap_mail",
"label": "IMAP 邮箱",
"description": "标准 IMAP 协议邮箱Gmail/QQ/163等仅用于接收验证码强制直连",
"config_fields": [
{"name": "host", "label": "IMAP 服务器", "required": True, "placeholder": "imap.gmail.com"},
{"name": "port", "label": "端口", "required": False, "default": 993},
{"name": "use_ssl", "label": "使用 SSL", "required": False, "default": True},
{"name": "email", "label": "邮箱地址", "required": True},
{"name": "password", "label": "密码/授权码", "required": True, "secret": True},
]
}
]
}

View File

@@ -372,6 +372,20 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
logger.info(f"使用数据库 Freemail 服务: {db_service.name}")
else:
raise ValueError("没有可用的 Freemail 邮箱服务,请先在邮箱服务页面添加服务")
elif service_type == EmailServiceType.IMAP_MAIL:
from ...database.models import EmailService as EmailServiceModel
db_service = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "imap_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).first()
if db_service and db_service.config:
config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}")
else:
raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加")
else:
config = email_service_config or {}
@@ -1110,6 +1124,11 @@ async def get_available_email_services():
"available": False,
"count": 0,
"services": []
},
"imap_mail": {
"available": False,
"count": 0,
"services": []
}
}
@@ -1219,6 +1238,25 @@ async def get_available_email_services():
result["freemail"]["count"] = len(freemail_services)
result["freemail"]["available"] = len(freemail_services) > 0
imap_mail_services = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "imap_mail",
EmailServiceModel.enabled == True
).order_by(EmailServiceModel.priority.asc()).all()
for service in imap_mail_services:
config = service.config or {}
result["imap_mail"]["services"].append({
"id": service.id,
"name": service.name,
"type": "imap_mail",
"email": config.get("email"),
"host": config.get("host"),
"priority": service.priority
})
result["imap_mail"]["count"] = len(imap_mail_services)
result["imap_mail"]["available"] = len(imap_mail_services) > 0
return result