"""Alias + Reader 配对 MailProvider — 兼容 alias forwarding 注册场景。 Addy.io / SimpleLogin 这类 alias forwarding 服务的 alias 域不在 OpenAI 公开 disposable 黑名单,适合用作注册时的"未黑域名"邮箱来源,但**没有内置 inbox** —— 收到的邮件会被 转发到底层真实邮箱(用户的 ProtonMail / 自托管 Postfix / maillab / cf 等)。 `AliasWithReaderProvider` 把两个 provider 组合成一个 `MailProvider`,对外暴露统一接口: - 写入路径(`create_temp_email` / `delete_account` / `list_accounts`)走 **alias**: 拿到不在黑名单的临时邮箱 - 读取路径(`search_emails_by_recipient` / `list_emails` / `delete_emails_for` / `wait_for_email` / `get_latest_emails`)走 **reader**:在底层真实邮箱里查 OpenAI 转发过来的 OTP / 邀请邮件 ⚠️ 关键假设(运维侧配置,不在代码内): 用户已在 Addy.io / SimpleLogin 控制台把 alias 的转发目标设为 reader 关联的真实邮箱 (例如 maillab 创建的子邮箱地址)。本 provider 不负责验证转发链路,只负责调用对接。 详见 S4 PRD §Q3 与 S2 PRD §Q2(alias forwarding 留白)。 """ from __future__ import annotations import logging from typing import Any from autoteam.mail.base import MailProvider logger = logging.getLogger(__name__) class AliasWithReaderProvider(MailProvider): """组合一个 alias provider(写)+ 一个 reader provider(读)。 Args: alias: 实现 `create_temp_email` / `delete_account` / `list_accounts` 能拿到不在 OpenAI 黑名单的邮箱地址(如 Addy.io / SimpleLogin)。 reader: 实现 `search_emails_by_recipient` / `list_emails` / `delete_emails_for` 能读到 alias 转发过来的邮件(如 maillab / cf_temp_email / IMAP 自建)。 Usage: pair = AliasWithReaderProvider(alias=AddyIoClient(), reader=MaillabClient()) acc_id, email = pair.create_temp_email(prefix="autoteam") # OpenAI 注册 → OTP 邮件被转发到 reader 的真实邮箱 otp_email = pair.wait_for_email(email, timeout=180, sender_keyword="openai") """ provider_name = "alias_with_reader" def __init__(self, alias: MailProvider, reader: MailProvider): if alias is None or reader is None: raise ValueError("AliasWithReaderProvider 需要同时提供 alias 与 reader 两个 provider") self.alias: MailProvider = alias self.reader: MailProvider = reader # 复合名:便于日志 / cache_key 区分 self.provider_name = ( f"alias_with_reader[{getattr(alias, 'provider_name', 'alias')}+" f"{getattr(reader, 'provider_name', 'reader')}]" ) # ------------------------------------------------------------------ auth def login(self) -> str: """同时初始化 alias 与 reader,任一抛异常视为整体不可用。 返回值仅用于"是否成功登录"的简单语义,**不**包含任何真实 token — 历史版本曾把两个 provider 的 token 用 ``|`` 拼接返回,有调用方误存到 accounts.json / log 的风险(round-12 wire-up audit minor m1)。 现在改为返回固定 ``"ok"`` 字面量,token 仅在 provider 实例内部保持。 """ self.alias.login() self.reader.login() logger.info( "[alias_with_reader] login OK (alias=%s, reader=%s)", getattr(self.alias, "provider_name", "alias"), getattr(self.reader, "provider_name", "reader"), ) return "ok" # ------------------------------------------------------------------ write → alias def create_temp_email( self, prefix: str | None = None, domain: str | None = None ) -> tuple[int | str, str]: return self.alias.create_temp_email(prefix=prefix, domain=domain) def delete_account(self, account_id: int | str) -> dict: return self.alias.delete_account(account_id) def list_accounts(self, size: int = 200) -> list[dict]: return self.alias.list_accounts(size=size) # ------------------------------------------------------------------ read → reader def search_emails_by_recipient( self, to_email: str, size: int = 10, account_id: int | str | None = None ) -> list[dict]: return self.reader.search_emails_by_recipient( to_email, size=size, account_id=account_id ) def list_emails(self, account_id: int | str, size: int = 10) -> list[dict]: return self.reader.list_emails(account_id, size=size) def delete_emails_for(self, to_email: str) -> int: return self.reader.delete_emails_for(to_email) def get_latest_emails( self, account_id: int | str, email_id: int = 0, all_receive: int = 0 ) -> list[dict]: return self.reader.get_latest_emails(account_id, email_id=email_id, all_receive=all_receive) # ------------------------------------------------------------------ shared (text 工具) — 用 reader 的实现 def extract_verification_code(self, email_data: dict) -> str | None: return self.reader.extract_verification_code(email_data) def extract_invite_link(self, email_data: dict) -> str | None: return self.reader.extract_invite_link(email_data) def wait_for_email( self, to_email: str, timeout: int | None = None, sender_keyword: str | None = None, ) -> dict: # 显式委派 reader,避免 base.wait_for_email 默认实现回头调本类的 # search_emails_by_recipient(本类已委派到 reader,效果一致,但语义更明确)。 return self.reader.wait_for_email(to_email, timeout=timeout, sender_keyword=sender_keyword) # ------------------------------------------------------------------ introspection def __repr__(self) -> str: # pragma: no cover - 调试辅助 return ( f"AliasWithReaderProvider(" f"alias={getattr(self.alias, 'provider_name', type(self.alias).__name__)}, " f"reader={getattr(self.reader, 'provider_name', type(self.reader).__name__)})" ) def describe(self) -> dict[str, Any]: """供日志 / UI 展示的结构化描述。""" return { "type": "alias_with_reader", "alias": getattr(self.alias, "provider_name", type(self.alias).__name__), "reader": getattr(self.reader, "provider_name", type(self.reader).__name__), } __all__ = ["AliasWithReaderProvider"]