ForcePilot/backend/package/yuxi/channel/extensions/email_smtp/gateway.py
Kris 59c6caaa64 feat(email-smtp): 新增SMTP/IMAP邮件渠道插件
实现完整的邮件收发渠道,支持IMAP IDLE实时收信、SMTP发信,包含附件校验、重复消息去重、OAuth2认证、邮件内容解析与引用剥离、邮件发送限流与连接池等功能
2026-05-21 10:45:56 +08:00

158 lines
6.3 KiB
Python

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)