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

199 lines
7.4 KiB
Python

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 = (
'<div style="display:none;font-size:0;line-height:0;max-height:0;overflow:hidden;">--reply-above-this--</div>'
)
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