from __future__ import annotations import asyncio import logging from yuxi.channel.context import ChannelContext from .bounce_handler import EmailBounceHandler from .config import EmailSmtpConfigAdapter from .dedupe import EmailDeduplicator from .imap_client import ImapClient from .monitor import EmailSmtpMonitor from .outbound import EmailSmtpOutboundAdapter from .smtp_client import SmtpClientManager from .types import EmailAccount, ImapConnectionState logger = logging.getLogger(__name__) class EmailSmtpGatewayAdapter: def __init__(self, outbound: EmailSmtpOutboundAdapter | None = None, dedupe: EmailDeduplicator | None = None): self._active_imap_clients: dict[str, ImapClient] = {} self._smtp_managers: dict[str, SmtpClientManager] = {} self._imap_states: dict[str, ImapConnectionState] = {} self._config_adapter = EmailSmtpConfigAdapter() self._monitor = EmailSmtpMonitor() self._deduplicator = dedupe or EmailDeduplicator() self._bounce_handler = EmailBounceHandler() self._outbound = outbound async def start(self, ctx: ChannelContext) -> object: account_dict = self._config_adapter.resolve_account(ctx.account_id, ctx.config) account = EmailSmtpConfigAdapter.make_email_account(account_dict) account_id = account.account_id self._imap_states[account_id] = ImapConnectionState() smtp_config = account.smtp smtp_mgr = SmtpClientManager(smtp_config) self._smtp_managers[account_id] = smtp_mgr if self._outbound: self._outbound.set_smtp_managers(self._smtp_managers) self._outbound.set_imap_clients(self._active_imap_clients) imap_client = ImapClient( host=account.imap.host, port=account.imap.port, username=account.imap.username, password=account.imap.password, use_ssl=account.imap.use_ssl, idle_timeout_secs=account.imap.idle_timeout_secs, poll_fallback_secs=account.imap.poll_fallback_secs, max_fetch_per_cycle=account.imap.max_fetch_per_cycle, ) self._active_imap_clients[account_id] = imap_client reconnect_delay = 1 while not ctx.cancel_event.is_set(): try: if account.oauth2_enabled and account.oauth2_refresh_token: from .oauth2 import EmailOAuth2Manager provider = account.email_address.split("@")[-1].split(".")[0] oauth2_mgr = EmailOAuth2Manager( provider=provider, client_id=account.oauth2_client_id, client_secret=account.oauth2_client_secret, ) access_token = await oauth2_mgr.get_access_token(account.oauth2_refresh_token) await imap_client.connect_oauth2(access_token) else: await imap_client.connect() self._imap_states[account_id].connected = True self._imap_states[account_id].reconnect_count = 0 self._imap_states[account_id].uid_validity = imap_client.uid_validity reconnect_delay = 1 if imap_client.uid_validity != self._imap_states[account_id].uid_validity: self._deduplicator.handle_uidvalidity_change() self._imap_states[account_id].uid_validity = imap_client.uid_validity async for parsed_email in imap_client.listen(ctx.cancel_event): await self._handle_inbound(parsed_email, account, ctx) except asyncio.CancelledError: break except Exception as e: self._imap_states[account_id].connected = False self._imap_states[account_id].last_error = str(e) logger.error( "IMAP connection error for %s: %s, retrying in %ds", account_id, e, reconnect_delay, ) await asyncio.sleep(reconnect_delay) reconnect_delay = min(reconnect_delay * 2, 300) finally: try: await imap_client.disconnect() except Exception: pass self._imap_states[account_id].connected = False return ctx.queue async def stop(self, ctx: ChannelContext) -> None: account_id = ctx.account_id client = self._active_imap_clients.pop(account_id, None) if client: await client.disconnect() smtp_mgr = self._smtp_managers.pop(account_id, None) if smtp_mgr: await smtp_mgr.close() self._imap_states.pop(account_id, None) async def _handle_inbound(self, parsed_email, account: EmailAccount, ctx: ChannelContext): if self._deduplicator.is_duplicate(parsed_email.message_id, parsed_email.uid): return auto_submitted = parsed_email.auto_submitted or "" if auto_submitted.lower() in ("auto-replied", "auto-generated"): logger.debug("Skipping auto-submitted email: %s", parsed_email.message_id) return bounce_info = self._bounce_handler.analyze(parsed_email) if bounce_info.is_bounce: logger.warning( "Bounce email detected from=%s recipient=%s code=%s retryable=%s", parsed_email.from_email, bounce_info.original_recipient, bounce_info.status_code, bounce_info.retryable, ) self._deduplicator.mark(parsed_email.message_id, parsed_email.uid) return self._deduplicator.mark(parsed_email.message_id, parsed_email.uid) unified = self._monitor.parse_to_unified(parsed_email, account) if self._outbound: self._outbound.set_last_message_metadata(unified.metadata or {}) if ctx.queue is not None: await ctx.queue.put(unified) def get_imap_client(self, account_id: str) -> ImapClient | None: return self._active_imap_clients.get(account_id) def get_smtp_manager(self, account_id: str) -> SmtpClientManager | None: return self._smtp_managers.get(account_id) def get_imap_state(self, account_id: str) -> ImapConnectionState | None: return self._imap_states.get(account_id)