From 0059cf97bd45c6207f1025b78df986309990926f Mon Sep 17 00:00:00 2001 From: cnlimiter Date: Fri, 20 Mar 2026 15:37:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(services):=20=E6=96=B0=E5=A2=9E=E6=A0=87?= =?UTF-8?q?=E5=87=86=20IMAP=20=E9=82=AE=E7=AE=B1=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=88Gmail/QQ/163=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 EmailServiceType.IMAP_MAIL 枚举值和默认配置 - 新建 ImapMailService(imaplib 标准库,强制直连) - 注册路由新增 imap_mail 分支和 available-services 键 - 邮箱服务路由新增 imap_mail stats 统计和类型描述 - accounts 路由 _build_inbox_config 支持 imap_mail - 前端表单/列表/编辑完整支持 IMAP 子类型 - 无新增依赖 --- src/config/constants.py | 10 ++ src/services/__init__.py | 3 + src/services/imap_mail.py | 217 +++++++++++++++++++++++++++++++++ src/web/routes/accounts.py | 1 + src/web/routes/email.py | 15 +++ src/web/routes/registration.py | 38 ++++++ static/js/email_services.js | 64 ++++++++-- static/js/utils.js | 3 +- templates/email_services.html | 59 +++++++++ templates/index.html | 1 + 10 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 src/services/imap_mail.py diff --git a/src/config/constants.py b/src/config/constants.py index f9b3fd3..b65bff6 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -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, } } diff --git a/src/services/__init__.py b/src/services/__init__.py index 10e1813..ad29d3e 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -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', diff --git a/src/services/imap_mail.py b/src/services/imap_mail.py new file mode 100644 index 0000000..01573f6 --- /dev/null +++ b/src/services/imap_mail.py @@ -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 diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 44d4b7a..a6a597f 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -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) diff --git a/src/web/routes/email.py b/src/web/routes/email.py index c45666c..5f0123c 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -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}, + ] } ] } diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 50b0b36..be6051b 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -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 diff --git a/static/js/email_services.js b/static/js/email_services.js index 9f5b8e9..fafd85b 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -4,7 +4,7 @@ // 状态 let outlookServices = []; -let customServices = []; // 合并 moe_mail + temp_mail + duck_mail +let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail let selectedOutlook = new Set(); let selectedCustom = new Set(); @@ -52,6 +52,7 @@ const elements = { addTempmailFields: document.getElementById('add-tempmail-fields'), addDuckmailFields: document.getElementById('add-duckmail-fields'), addFreemailFields: document.getElementById('add-freemail-fields'), + addImapFields: document.getElementById('add-imap-fields'), // 编辑自定义域名模态框 editCustomModal: document.getElementById('edit-custom-modal'), @@ -62,6 +63,7 @@ const elements = { editTempmailFields: document.getElementById('edit-tempmail-fields'), editDuckmailFields: document.getElementById('edit-duckmail-fields'), editFreemailFields: document.getElementById('edit-freemail-fields'), + editImapFields: document.getElementById('edit-imap-fields'), editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'), @@ -76,7 +78,8 @@ const CUSTOM_SUBTYPE_LABELS = { moemail: '🔗 MoeMail(自定义域名 API)', tempmail: '📮 TempMail(自部署 Cloudflare Worker)', duckmail: '🦆 DuckMail(DuckMail API)', - freemail: 'Freemail(自部署 Cloudflare Worker)' + freemail: 'Freemail(自部署 Cloudflare Worker)', + imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' }; // 初始化 @@ -182,6 +185,7 @@ function switchAddSubType(subType) { elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; } // 切换编辑表单子类型显示 @@ -191,6 +195,7 @@ function switchEditSubType(subType) { elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; } @@ -199,7 +204,7 @@ async function loadStats() { try { const data = await api.get('/email-services/stats'); elements.outlookCount.textContent = data.outlook_count || 0; - elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0); + elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; elements.totalEnabled.textContent = data.enabled_count || 0; } catch (error) { @@ -281,10 +286,18 @@ function getCustomServiceTypeBadge(subType) { if (subType === 'duckmail') { return 'DuckMail'; } - return 'Freemail'; + if (subType === 'freemail') { + return 'Freemail'; + } + return 'IMAP'; } function getCustomServiceAddress(service) { + if (service._subType === 'imap') { + const host = service.config?.host || '-'; + const emailAddr = service.config?.email || ''; + return `${escapeHtml(host)}
${escapeHtml(emailAddr)}
`; + } const baseUrl = service.config?.base_url || '-'; const domain = service.config?.default_domain || service.config?.domain; if (!domain) { @@ -296,17 +309,19 @@ function getCustomServiceAddress(service) { // 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并) async function loadCustomServices() { try { - const [r1, r2, r3, r4] = await Promise.all([ + const [r1, r2, r3, r4, r5] = await Promise.all([ api.get('/email-services?service_type=moe_mail'), api.get('/email-services?service_type=temp_mail'), api.get('/email-services?service_type=duck_mail'), - api.get('/email-services?service_type=freemail') + api.get('/email-services?service_type=freemail'), + api.get('/email-services?service_type=imap_mail') ]); customServices = [ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })), ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })), - ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })) + ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })), + ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })) ]; if (customServices.length === 0) { @@ -444,13 +459,22 @@ async function handleAddCustom(e) { default_domain: formData.get('dm_domain'), password_length: parseInt(formData.get('dm_password_length'), 10) || 12 }; - } else { + } else if (subType === 'freemail') { serviceType = 'freemail'; config = { base_url: formData.get('fm_base_url'), admin_token: formData.get('fm_admin_token'), domain: formData.get('fm_domain') }; + } else { + serviceType = 'imap_mail'; + config = { + host: formData.get('imap_host'), + port: parseInt(formData.get('imap_port'), 10) || 993, + use_ssl: formData.get('imap_use_ssl') !== 'false', + email: formData.get('imap_email'), + password: formData.get('imap_password') + }; } const data = { @@ -593,7 +617,9 @@ async function editCustomService(id, subType) { ? 'duckmail' : service.service_type === 'freemail' ? 'freemail' - : 'moemail' + : service.service_type === 'imap_mail' + ? 'imap' + : 'moemail' ); document.getElementById('edit-custom-id').value = service.id; @@ -619,11 +645,18 @@ async function editCustomService(id, subType) { document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)'; document.getElementById('edit-dm-domain').value = service.config?.default_domain || ''; document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12; - } else { + } else if (resolvedSubType === 'freemail') { document.getElementById('edit-fm-base-url').value = service.config?.base_url || ''; document.getElementById('edit-fm-admin-token').value = ''; document.getElementById('edit-fm-admin-token').placeholder = service.config?.admin_token ? '已设置,留空保持不变' : '请输入 Admin Token'; document.getElementById('edit-fm-domain').value = service.config?.domain || ''; + } else { + document.getElementById('edit-imap-host').value = service.config?.host || ''; + document.getElementById('edit-imap-port').value = service.config?.port || 993; + document.getElementById('edit-imap-use-ssl').value = service.config?.use_ssl !== false ? 'true' : 'false'; + document.getElementById('edit-imap-email').value = service.config?.email || ''; + document.getElementById('edit-imap-password').value = ''; + document.getElementById('edit-imap-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码/授权码'; } elements.editCustomModal.classList.add('active'); @@ -663,13 +696,22 @@ async function handleEditCustom(e) { }; const apiKey = formData.get('dm_api_key'); if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); - } else { + } else if (subType === 'freemail') { config = { base_url: formData.get('fm_base_url'), domain: formData.get('fm_domain') }; const token = formData.get('fm_admin_token'); if (token && token.trim()) config.admin_token = token.trim(); + } else { + config = { + host: formData.get('imap_host'), + port: parseInt(formData.get('imap_port'), 10) || 993, + use_ssl: formData.get('imap_use_ssl') !== 'false', + email: formData.get('imap_email') + }; + const pwd = formData.get('imap_password'); + if (pwd && pwd.trim()) config.password = pwd.trim(); } const updateData = { diff --git a/static/js/utils.js b/static/js/utils.js index 84b88cf..b7b5dab 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -354,7 +354,8 @@ const statusMap = { moe_mail: 'MoeMail', temp_mail: 'Temp-Mail(自部署)', duck_mail: 'DuckMail', - freemail: 'Freemail' + freemail: 'Freemail', + imap_mail: 'IMAP 邮箱' } }; diff --git a/templates/email_services.html b/templates/email_services.html index aab27b2..4b18766 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -211,6 +211,7 @@ + @@ -277,6 +278,35 @@ + +
@@ -386,6 +416,35 @@
+ +
diff --git a/templates/index.html b/templates/index.html index dd7a802..7d468b7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -181,6 +181,7 @@ +