158 lines
6.3 KiB
Python
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)
|