from __future__ import annotations import logging import os from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email import encoders from email.utils import formataddr, formatdate, make_msgid import aiohttp from .attachment import is_attachment_safe from .config import EmailSmtpConfigAdapter from .format import build_email_html logger = logging.getLogger(__name__) REPLY_SEPARATOR_HTML = ( '
--reply-above-this--
' ) class EmailSmtpOutboundAdapter: def __init__(self, smtp_managers: dict | None = None, imap_clients: dict | None = None): self._smtp_managers: dict = smtp_managers or {} self._imap_clients: dict = imap_clients or {} self._config_adapter = EmailSmtpConfigAdapter() self._last_message_metadata: dict = {} def set_smtp_managers(self, managers: dict): self._smtp_managers = managers def set_imap_clients(self, clients: dict): self._imap_clients = clients def set_last_message_metadata(self, metadata: dict): self._last_message_metadata = metadata 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, priority: str | None = None, ) -> None: aid = account_id or "default" account_dict = self._config_adapter.resolve_account(aid) account = EmailSmtpConfigAdapter.make_email_account(account_dict) smtp_mgr = self._smtp_managers.get(aid) if not smtp_mgr: logger.error("SMTP manager not found for account %s", aid) return html_body = build_email_html(content) msg_id = make_msgid(domain=account.email_address.split("@")[-1]) msg = MIMEMultipart("alternative") msg["From"] = formataddr((account.display_name, account.email_address)) msg["To"] = target_id if cc_list: msg["Cc"] = ", ".join(cc_list) msg["Subject"] = self._resolve_subject() msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = msg_id msg["Auto-Submitted"] = "auto-replied" msg["X-Mailer"] = "ForcePilot AI/1.0" if priority: self._set_priority_headers(msg, priority) if reply_to_id: msg["In-Reply-To"] = f"<{reply_to_id}>" msg["References"] = f"<{reply_to_id}>" msg.attach(MIMEText(content, "plain", "utf-8")) msg.attach(MIMEText(html_body + REPLY_SEPARATOR_HTML, "html", "utf-8")) all_recipients = [target_id] if cc_list: all_recipients.extend(cc_list) if bcc_list: all_recipients.extend(bcc_list) result = await smtp_mgr.send_message(msg, account, recipients=all_recipients) if result.success: try: imap_client = self._imap_clients.get(aid) if imap_client: await imap_client.append_to_sent(msg.as_bytes()) except Exception: logger.warning("Failed to append to Sent folder", exc_info=True) else: logger.error("SMTP send failed: %s", result.error) 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: aid = account_id or "default" account_dict = self._config_adapter.resolve_account(aid) account = EmailSmtpConfigAdapter.make_email_account(account_dict) smtp_mgr = self._smtp_managers.get(aid) if not smtp_mgr: logger.error("SMTP manager not found for account %s", aid) return content_type_map = { "image": ("image/png", ".png"), "file": ("application/octet-stream", ""), "pdf": ("application/pdf", ".pdf"), } mime_main, default_ext = content_type_map.get(media_type, ("application/octet-stream", "")) try: async with aiohttp.ClientSession() as session: async with session.get(media_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: logger.error("Failed to fetch media from %s: HTTP %s", media_url, resp.status) return file_data = await resp.read() except Exception as e: logger.error("Failed to download media from %s: %s", media_url, e) return filename = os.path.basename(media_url) or f"attachment{default_ext}" safe, reason = is_attachment_safe( filename=filename, content_type=mime_main, size=len(file_data), ) if not safe: logger.warning("Media rejected by safety filter: %s", reason) return msg = MIMEMultipart("mixed") msg["From"] = formataddr((account.display_name, account.email_address)) msg["To"] = target_id msg["Subject"] = self._resolve_subject() msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=account.email_address.split("@")[-1]) msg["Auto-Submitted"] = "auto-replied" msg["X-Mailer"] = "ForcePilot AI/1.0" if reply_to_id: msg["In-Reply-To"] = f"<{reply_to_id}>" msg["References"] = f"<{reply_to_id}>" body = MIMEMultipart("alternative") plain_content = f"[附件] {media_type}" body.attach(MIMEText(plain_content, "plain", "utf-8")) msg.attach(body) attachment_part = MIMEBase(*mime_main.split("/", 1)) attachment_part.set_payload(file_data) encoders.encode_base64(attachment_part) attachment_part.add_header("Content-Disposition", "attachment", filename=filename) msg.attach(attachment_part) result = await smtp_mgr.send_message(msg, account) if result.success: try: imap_client = self._imap_clients.get(aid) if imap_client: await imap_client.append_to_sent(msg.as_bytes()) except Exception: logger.warning("Failed to append media to Sent folder", exc_info=True) else: logger.error("SMTP media send failed: %s", result.error) def _resolve_subject(self) -> str: original_subject = self._last_message_metadata.get("subject", "") if original_subject and not original_subject.lower().startswith("re:"): return f"Re: {original_subject}" return original_subject or "Re: \u6765\u81ea AI \u5ba2\u670d\u7684\u56de\u590d" @staticmethod def _set_priority_headers(msg: MIMEMultipart, priority: str) -> None: priority_map = { "high": {"X-Priority": "1", "X-MSMail-Priority": "High", "Importance": "high"}, "normal": {"X-Priority": "3", "X-MSMail-Priority": "Normal", "Importance": "normal"}, "low": {"X-Priority": "5", "X-MSMail-Priority": "Low", "Importance": "low"}, } headers = priority_map.get(priority.lower(), priority_map["normal"]) for key, value in headers.items(): msg[key] = value