from __future__ import annotations import asyncio import logging from yuxi.channel.capabilities import ChannelCapabilities from yuxi.channel.extensions.base import BaseChannelPlugin from yuxi.channel.protocols import SessionResolution from .config import EmailSmtpConfigAdapter from .dedupe import EmailDeduplicator from .format import build_email_system_prompt from .gateway import EmailSmtpGatewayAdapter from .outbound import EmailSmtpOutboundAdapter from .pairing import EmailSmtpPairingAdapter from .security import EmailSmtpSecurityAdapter from .session import EmailSmtpSessionAdapter from .status import EmailSmtpStatusAdapter from .streaming import EmailSmtpStreamingAdapter logger = logging.getLogger(__name__) class EmailSmtpPlugin(BaseChannelPlugin): id = "email-smtp" name = "\u90ae\u4ef6 (SMTP/IMAP)" order = 80 label = "\u90ae\u4ef6" aliases = ["email", "smtp", "imap", "mail"] def __init__(self): self._config = EmailSmtpConfigAdapter() self._outbound = EmailSmtpOutboundAdapter() self._dedupe = EmailDeduplicator() self._gateway = EmailSmtpGatewayAdapter(self._outbound, dedupe=self._dedupe) self._security = EmailSmtpSecurityAdapter() self._pairing = EmailSmtpPairingAdapter() self._session = EmailSmtpSessionAdapter() self._streaming = EmailSmtpStreamingAdapter(self._outbound) self._status = EmailSmtpStatusAdapter() @property def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=["direct"], message_types=["text"], reactions=False, typing_indicator=False, threads=True, edit=False, unsend=False, reply=True, media=True, native_commands=False, polls=False, streaming=True, streaming_mode="block", block_streaming=True, block_streaming_chunk_min_chars=800, block_streaming_chunk_max_chars=1200, block_streaming_chunk_break_preference="paragraph", ) def list_account_ids(self, config: dict) -> list[str]: return self._config.list_account_ids(config) async def resolve_account(self, account_id: str) -> dict: return self._config.resolve_account(account_id) def is_configured(self, account: dict) -> bool: return self._config.is_configured(account) def is_enabled(self, account: dict) -> bool: return self._config.is_enabled(account) def disabled_reason(self, account: dict) -> str: return self._config.disabled_reason(account) def describe_account(self, account: dict) -> dict: return self._config.describe_account(account) async def start(self, ctx) -> object: return await self._gateway.start(ctx) async def stop(self, ctx) -> None: await self._gateway.stop(ctx) async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, cc_list: list[str] | None = None, bcc_list: list[str] | None = None, ) -> None: await self._outbound.send_text( target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id, cc_list=cc_list, bcc_list=bcc_list, ) async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: await self._outbound.send_media( target_id, media_url, media_type, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id, ) async def probe(self, account: dict) -> bool: return await self._status.probe(account) def build_summary(self, snapshot: object) -> dict: return self._status.build_summary(snapshot) async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: return self._security.check_allowlist(peer_id) def resolve_dm_policy(self) -> dict: return self._security.resolve_dm_policy() async def generate_code(self, peer_id: str) -> str: return self._pairing.generate_code(peer_id) async def verify_code(self, peer_id: str, code: str) -> bool: return self._pairing.verify_code(peer_id, code) def normalize_allow_entry(self, entry: str) -> str: return self._pairing.normalize_allow_entry(entry) def is_duplicate(self, key: str) -> bool: return self._dedupe.is_duplicate(key) def mark_seen(self, key: str) -> None: self._dedupe.mark(key) def build_system_prompt(self, context) -> str | None: return build_email_system_prompt() def build_context_note(self, context) -> str: return "" def extract_thread_id(self, msg: object) -> str | None: if hasattr(msg, "message_thread_id"): return msg.message_thread_id return None def resolve_session(self, msg: object) -> SessionResolution: from yuxi.channel.message.models import PeerKind if hasattr(msg, "sender") and hasattr(msg.sender, "kind"): if msg.sender.kind == PeerKind.DIRECT: session = self._session.resolve_session(msg) return SessionResolution( kind="direct", conversation_id=msg.sender.id, thread_id=session.thread_id, label=session.label, ) gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown" return SessionResolution(kind="group", conversation_id=gid) @property def streaming_mode(self) -> str: return self._streaming.streaming_mode @property def preview_stream_throttle_ms(self) -> int: return self._streaming.preview_stream_throttle_ms @property def block_streaming_enabled(self) -> bool: return self._streaming.block_streaming_enabled @property def block_streaming_chunk_min_chars(self) -> int: return self._streaming.block_streaming_chunk_min_chars @property def block_streaming_chunk_max_chars(self) -> int: return self._streaming.block_streaming_chunk_max_chars @property def block_streaming_chunk_break_preference(self) -> str: return self._streaming.block_streaming_chunk_break_preference @property def block_streaming_coalesce_defaults(self) -> dict | None: return self._streaming.block_streaming_coalesce_defaults def create_block_chunker(self) -> object: return self._streaming.create_block_chunker() def create_draft_stream_session(self, target_id: str) -> object: return self._streaming.create_draft_stream_session(target_id) @property def channel_format_instructions(self) -> str | None: return ( "You are sending email replies via SMTP. " "Use plain paragraphs separated by blank lines. " "Use **bold** for emphasis. Do not use Markdown tables or code blocks. " "Each reply should be concise and focused on the user's question. " "Do not include greetings like 'Hello' or 'Dear' at the start. " "Sign-off is optional - the system will add its own footer." ) @property def ping_interval_ms(self) -> int: return 5 * 60 * 1000 async def on_ping(self, account: dict) -> bool: try: imap_client = self._gateway.get_imap_client(account.get("account_id", "default")) if imap_client and imap_client._mailbox: await asyncio.to_thread(imap_client._mailbox.noop) return True except Exception: return False async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: client = self._gateway.get_imap_client(account_id) if client: await client.disconnect() async def on_account_removed(self, account_id: str) -> None: client = self._gateway.get_imap_client(account_id) if client: await client.disconnect() async def on_message_received(self, msg: object) -> object | None: return msg async def on_message_sending(self, msg: object, content: str) -> str | None: logger.debug("Sending to %s: %s...", getattr(msg, "target_id", "unknown"), content[:100]) return content async def before_send_attempt(self, target_id: str, content: str) -> None: logger.debug("Before send attempt to %s", target_id) async def after_send_success(self, target_id: str, receipt: object) -> None: logger.info("Sent successfully to %s", target_id) async def after_send_failure(self, target_id: str, error: str) -> None: logger.error("Send failed to %s: %s", target_id, error) def config_schema(self) -> dict: return { "type": "object", "properties": { "accounts": { "type": "object", "description": "\u591a\u8d26\u6237\u914d\u7f6e", }, }, }