199 lines
7.4 KiB
Python
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
|