feat(email-smtp): 新增SMTP/IMAP邮件渠道插件
实现完整的邮件收发渠道,支持IMAP IDLE实时收信、SMTP发信,包含附件校验、重复消息去重、OAuth2认证、邮件内容解析与引用剥离、邮件发送限流与连接池等功能
This commit is contained in:
parent
4552bde837
commit
59c6caaa64
@ -0,0 +1,4 @@
|
|||||||
|
from yuxi.channel.extensions.email_smtp.plugin import EmailSmtpPlugin
|
||||||
|
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||||
|
|
||||||
|
ChannelPluginRegistry.register(EmailSmtpPlugin())
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
ALLOWED_MIME_TYPES = {
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
}
|
||||||
|
|
||||||
|
BLOCKED_EXTENSIONS = {
|
||||||
|
".exe",
|
||||||
|
".dll",
|
||||||
|
".bat",
|
||||||
|
".cmd",
|
||||||
|
".ps1",
|
||||||
|
".vbs",
|
||||||
|
".js",
|
||||||
|
".scr",
|
||||||
|
".pif",
|
||||||
|
".msi",
|
||||||
|
".com",
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_TOTAL_SIZE = 25 * 1024 * 1024
|
||||||
|
MAX_PER_ATTACHMENT = 10 * 1024 * 1024
|
||||||
|
MAX_TEXT_FOR_AI = 200 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def is_attachment_safe(filename: str, content_type: str, size: int) -> tuple[bool, str]:
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext in BLOCKED_EXTENSIONS:
|
||||||
|
return False, f"禁止的文件类型: {ext}"
|
||||||
|
if size > MAX_PER_ATTACHMENT:
|
||||||
|
return False, f"附件过大 ({size} bytes > {MAX_PER_ATTACHMENT})"
|
||||||
|
if content_type not in ALLOWED_MIME_TYPES:
|
||||||
|
return False, f"不支持的 MIME 类型: {content_type}"
|
||||||
|
return True, "ok"
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email import policy
|
||||||
|
|
||||||
|
from .types import ParsedEmail
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BOUNCE_INDICATORS = {
|
||||||
|
"auto-submitted": ["auto-generated", "auto-replied"],
|
||||||
|
"precedence": ["bulk", "auto_reply", "list"],
|
||||||
|
"x-failed-recipients": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
DSN_CONTENT_TYPES = {
|
||||||
|
"multipart/report",
|
||||||
|
"message/delivery-status",
|
||||||
|
"message/disposition-notification",
|
||||||
|
}
|
||||||
|
|
||||||
|
BOUNCE_SUBJECT_PATTERNS = [
|
||||||
|
re.compile(r"^\s*undeliver(ed|able)", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*delivery\s+(failure|failed|status|report)", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*returned\s+mail", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*bounce", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*mail\s+delivery\s+(failure|failed)", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*failure\s+notice", re.IGNORECASE),
|
||||||
|
re.compile(r"^\s*mail\s+system\s+error", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
BOUNCE_FROM_PATTERNS = [
|
||||||
|
re.compile(r"mailer-daemon", re.IGNORECASE),
|
||||||
|
re.compile(r"postmaster", re.IGNORECASE),
|
||||||
|
re.compile(r"mail\s+delivery\s+subsystem", re.IGNORECASE),
|
||||||
|
re.compile(r"\bdsn\b", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
SMTP_STATUS_CODES = {
|
||||||
|
"4.0.0": ("temporary_failure", True),
|
||||||
|
"4.1.1": ("bad_destination_mailbox", True),
|
||||||
|
"4.1.2": ("bad_destination_system", True),
|
||||||
|
"4.1.8": ("bad_sender_address", True),
|
||||||
|
"4.2.0": ("mailbox_unavailable", True),
|
||||||
|
"4.2.2": ("mailbox_full", True),
|
||||||
|
"4.3.0": ("other_mail_system", True),
|
||||||
|
"4.3.1": ("mail_system_full", True),
|
||||||
|
"4.3.2": ("system_not_accepting", True),
|
||||||
|
"4.4.0": ("routing_error", True),
|
||||||
|
"4.4.1": ("no_answer_from_host", True),
|
||||||
|
"4.4.2": ("bad_connection", True),
|
||||||
|
"4.4.5": ("mail_loop", True),
|
||||||
|
"4.6.0": ("other_protocol_status", True),
|
||||||
|
"4.7.0": ("security_error", True),
|
||||||
|
"4.7.1": ("delivery_not_authorized", True),
|
||||||
|
"5.0.0": ("permanent_failure", False),
|
||||||
|
"5.1.0": ("bad_destination_address", False),
|
||||||
|
"5.1.1": ("bad_destination_mailbox", False),
|
||||||
|
"5.1.2": ("bad_destination_system", False),
|
||||||
|
"5.1.3": ("bad_destination_mailbox_syntax", False),
|
||||||
|
"5.1.4": ("destination_ambiguous", False),
|
||||||
|
"5.1.6": ("destination_moved", False),
|
||||||
|
"5.1.7": ("bad_sender_address_syntax", False),
|
||||||
|
"5.1.8": ("bad_sender_address", False),
|
||||||
|
"5.2.0": ("mailbox_unavailable", False),
|
||||||
|
"5.2.1": ("mailbox_disabled", False),
|
||||||
|
"5.2.2": ("mailbox_full", False),
|
||||||
|
"5.2.3": ("message_too_long", False),
|
||||||
|
"5.2.4": ("mailing_list_expansion_problem", False),
|
||||||
|
"5.3.0": ("other_mail_system", False),
|
||||||
|
"5.3.1": ("mail_system_full", False),
|
||||||
|
"5.3.2": ("system_not_accepting", False),
|
||||||
|
"5.3.3": ("system_unable_to_process", False),
|
||||||
|
"5.3.4": ("message_too_big", False),
|
||||||
|
"5.4.0": ("routing_error", False),
|
||||||
|
"5.4.1": ("no_answer_from_host", False),
|
||||||
|
"5.4.2": ("bad_connection", False),
|
||||||
|
"5.4.3": ("routing_server_failure", False),
|
||||||
|
"5.4.4": ("unable_to_route", False),
|
||||||
|
"5.4.6": ("routing_loop_detected", False),
|
||||||
|
"5.4.7": ("delivery_time_expired", False),
|
||||||
|
"5.5.0": ("other_protocol_status", False),
|
||||||
|
"5.5.1": ("invalid_command", False),
|
||||||
|
"5.5.2": ("syntax_error", False),
|
||||||
|
"5.5.3": ("too_many_recipients", False),
|
||||||
|
"5.5.4": ("invalid_command_arguments", False),
|
||||||
|
"5.5.5": ("wrong_protocol_version", False),
|
||||||
|
"5.6.0": ("other_media_error", False),
|
||||||
|
"5.6.1": ("media_not_supported", False),
|
||||||
|
"5.6.2": ("conversion_required_and_prohibited", False),
|
||||||
|
"5.6.3": ("conversion_required_but_not_supported", False),
|
||||||
|
"5.6.4": ("conversion_with_loss_performed", False),
|
||||||
|
"5.6.5": ("conversion_failed", False),
|
||||||
|
"5.7.0": ("security_error", False),
|
||||||
|
"5.7.1": ("delivery_not_authorized", False),
|
||||||
|
"5.7.2": ("list_expansion_prohibited", False),
|
||||||
|
"5.7.3": ("security_conversion_required", False),
|
||||||
|
"5.7.4": ("security_features_not_supported", False),
|
||||||
|
"5.7.5": ("cryptographic_failure", False),
|
||||||
|
"5.7.6": ("cryptographic_algorithm_not_supported", False),
|
||||||
|
"5.7.7": ("message_integrity_failure", False),
|
||||||
|
"5.7.8": ("trust_anchor_not_found", False),
|
||||||
|
"5.7.9": ("signature_error", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BounceInfo:
|
||||||
|
is_bounce: bool = False
|
||||||
|
bounce_type: str = ""
|
||||||
|
original_recipient: str = ""
|
||||||
|
original_message_id: str = ""
|
||||||
|
status_code: str = ""
|
||||||
|
status_description: str = ""
|
||||||
|
retryable: bool = False
|
||||||
|
diagnostic_info: str = ""
|
||||||
|
bounce_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBounceHandler:
|
||||||
|
def analyze(self, parsed_email: ParsedEmail) -> BounceInfo:
|
||||||
|
info = BounceInfo()
|
||||||
|
|
||||||
|
if self._is_bounce_by_headers(parsed_email.raw_bytes):
|
||||||
|
info.is_bounce = True
|
||||||
|
info.bounce_type = "bounce"
|
||||||
|
info.bounce_reason = "bounce_headers_detected"
|
||||||
|
elif self._is_bounce_by_subject(parsed_email.subject):
|
||||||
|
info.is_bounce = True
|
||||||
|
info.bounce_type = "bounce"
|
||||||
|
info.bounce_reason = "bounce_subject_detected"
|
||||||
|
elif self._is_bounce_by_sender(parsed_email.from_email):
|
||||||
|
info.is_bounce = True
|
||||||
|
info.bounce_type = "bounce"
|
||||||
|
info.bounce_reason = "bounce_sender_detected"
|
||||||
|
|
||||||
|
if info.is_bounce:
|
||||||
|
self._extract_dsn_info(parsed_email.raw_bytes, info)
|
||||||
|
logger.info(
|
||||||
|
"Bounce detected: type=%s recipient=%s code=%s retryable=%s reason=%s",
|
||||||
|
info.bounce_type,
|
||||||
|
info.original_recipient,
|
||||||
|
info.status_code,
|
||||||
|
info.retryable,
|
||||||
|
info.bounce_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _is_bounce_by_headers(self, raw_bytes: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
auto_submitted = str(msg.get("Auto-Submitted", "")).lower()
|
||||||
|
if auto_submitted in BOUNCE_INDICATORS["auto-submitted"]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
precedence = str(msg.get("Precedence", "")).lower()
|
||||||
|
if precedence in BOUNCE_INDICATORS["precedence"]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if msg.get("X-Failed-Recipients"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
content_type = msg.get_content_type()
|
||||||
|
if content_type in DSN_CONTENT_TYPES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_bounce_by_subject(self, subject: str) -> bool:
|
||||||
|
if not subject:
|
||||||
|
return False
|
||||||
|
for pattern in BOUNCE_SUBJECT_PATTERNS:
|
||||||
|
if pattern.search(subject):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_bounce_by_sender(self, from_email: str) -> bool:
|
||||||
|
if not from_email:
|
||||||
|
return False
|
||||||
|
for pattern in BOUNCE_FROM_PATTERNS:
|
||||||
|
if pattern.search(from_email):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_dsn_info(self, raw_bytes: bytes, info: BounceInfo) -> None:
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
if content_type == "message/delivery-status":
|
||||||
|
self._parse_delivery_status(part, info)
|
||||||
|
elif content_type == "message/rfc822":
|
||||||
|
self._parse_original_message(part, info)
|
||||||
|
elif content_type == "text/plain":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
self._extract_recipient_from_text(payload.decode("utf-8", errors="replace"), info)
|
||||||
|
|
||||||
|
if not info.original_message_id:
|
||||||
|
info.original_message_id = str(msg.get("In-Reply-To", "")).strip("<>")
|
||||||
|
|
||||||
|
if not info.original_recipient:
|
||||||
|
failed_recipients = msg.get("X-Failed-Recipients")
|
||||||
|
if failed_recipients:
|
||||||
|
info.original_recipient = failed_recipients.split(",")[0].strip()
|
||||||
|
|
||||||
|
if info.status_code:
|
||||||
|
desc, retryable = SMTP_STATUS_CODES.get(info.status_code, ("unknown", False))
|
||||||
|
info.status_description = desc
|
||||||
|
info.retryable = retryable
|
||||||
|
|
||||||
|
def _parse_delivery_status(self, part, info: BounceInfo) -> None:
|
||||||
|
payload = part.get_payload(decode=False)
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for sub_msg in payload:
|
||||||
|
text = sub_msg.as_string()
|
||||||
|
self._parse_delivery_status_text(text, info)
|
||||||
|
elif payload:
|
||||||
|
text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload)
|
||||||
|
self._parse_delivery_status_text(text, info)
|
||||||
|
|
||||||
|
def _parse_delivery_status_text(self, text: str, info: BounceInfo) -> None:
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.lower().startswith("final-recipient:"):
|
||||||
|
match = re.search(r"rfc822;\s*(.+)", line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
info.original_recipient = match.group(1).strip()
|
||||||
|
elif line.lower().startswith("original-recipient:"):
|
||||||
|
match = re.search(r"rfc822;\s*(.+)", line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
info.original_recipient = match.group(1).strip()
|
||||||
|
elif line.lower().startswith("status:"):
|
||||||
|
info.status_code = line.split(":", 1)[1].strip()
|
||||||
|
elif line.lower().startswith("diagnostic-code:"):
|
||||||
|
info.diagnostic_info = line.split(":", 1)[1].strip()
|
||||||
|
elif line.lower().startswith("action:"):
|
||||||
|
action = line.split(":", 1)[1].strip().lower()
|
||||||
|
if action == "failed":
|
||||||
|
info.retryable = False
|
||||||
|
elif action in ("delayed", "transient"):
|
||||||
|
info.retryable = True
|
||||||
|
|
||||||
|
def _parse_original_message(self, part, info: BounceInfo) -> None:
|
||||||
|
try:
|
||||||
|
original_msg = email.message_from_bytes(part.get_payload(decode=True) or b"", policy=policy.default)
|
||||||
|
info.original_message_id = str(original_msg.get("Message-ID", "")).strip("<>")
|
||||||
|
if not info.original_recipient:
|
||||||
|
to_header = str(original_msg.get("To", ""))
|
||||||
|
if to_header:
|
||||||
|
info.original_recipient = to_header.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _extract_recipient_from_text(self, text: str, info: BounceInfo) -> None:
|
||||||
|
if info.original_recipient:
|
||||||
|
return
|
||||||
|
patterns = [
|
||||||
|
re.compile(r"[<\[]([^>@\s]+@[^>\]\s]+)[>\]]"),
|
||||||
|
re.compile(r"\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b"),
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = pattern.search(text)
|
||||||
|
if match:
|
||||||
|
info.original_recipient = match.group(1).strip()
|
||||||
|
return
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email.header
|
||||||
|
|
||||||
|
from charset_normalizer import from_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def decode_mime_header(raw: str) -> str:
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
parts = email.header.decode_header(raw)
|
||||||
|
result: list[str] = []
|
||||||
|
for decoded_part, charset in parts:
|
||||||
|
if isinstance(decoded_part, bytes):
|
||||||
|
if charset:
|
||||||
|
try:
|
||||||
|
result.append(decoded_part.decode(charset))
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
detected = from_bytes(decoded_part).best()
|
||||||
|
result.append(str(detected) if detected else decoded_part.decode("utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
detected = from_bytes(decoded_part).best()
|
||||||
|
result.append(str(detected) if detected else decoded_part.decode("utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
result.append(decoded_part)
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_decode_payload(payload: bytes, declared_charset: str | None) -> str:
|
||||||
|
if not payload:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not declared_charset:
|
||||||
|
detected = from_bytes(payload).best()
|
||||||
|
return str(detected) if detected else payload.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return payload.decode(declared_charset)
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
if declared_charset.lower() in ("gb2312", "gbk", "gb18030"):
|
||||||
|
for enc in ("gb18030", "gbk", "gb2312"):
|
||||||
|
try:
|
||||||
|
return payload.decode(enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
detected = from_bytes(payload).best()
|
||||||
|
return str(detected) if detected else payload.decode("utf-8", errors="replace")
|
||||||
173
backend/package/yuxi/channel/extensions/email_smtp/config.py
Normal file
173
backend/package/yuxi/channel/extensions/email_smtp/config.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
from .types import EmailAccount, ImapConfig, SmtpConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCredentialVault:
|
||||||
|
def __init__(self, master_key_path: str = "saves/.secret/email_vault.key"):
|
||||||
|
self._master_key = self._load_or_create_key(master_key_path)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> bytes:
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
aesgcm = AESGCM(self._master_key)
|
||||||
|
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||||
|
return nonce + ct
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes) -> str:
|
||||||
|
nonce, ct = ciphertext[:12], ciphertext[12:]
|
||||||
|
aesgcm = AESGCM(self._master_key)
|
||||||
|
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
||||||
|
|
||||||
|
def _load_or_create_key(self, path: str) -> bytes:
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
key = AESGCM.generate_key(bit_length=256)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpConfigAdapter:
|
||||||
|
def __init__(self):
|
||||||
|
self._vault = EmailCredentialVault()
|
||||||
|
|
||||||
|
def list_account_ids(self, config: dict) -> list[str]:
|
||||||
|
accounts = config.get("accounts", {})
|
||||||
|
if not accounts:
|
||||||
|
return ["default"]
|
||||||
|
return list(accounts.keys())
|
||||||
|
|
||||||
|
def resolve_account(self, account_id: str, config: dict | None = None) -> dict:
|
||||||
|
config = config or self._load_config()
|
||||||
|
accounts = config.get("accounts", {})
|
||||||
|
acct = accounts.get(account_id, {})
|
||||||
|
|
||||||
|
email = acct.get("email_address", config.get("base_email_address", ""))
|
||||||
|
return {
|
||||||
|
"account_id": account_id,
|
||||||
|
"email_address": email,
|
||||||
|
"display_name": acct.get("display_name", config.get("base_display_name", "AI \u5ba2\u670d")),
|
||||||
|
"smtp_host": acct.get("smtp_host", config.get("base_smtp_host", "")),
|
||||||
|
"smtp_port": acct.get("smtp_port", config.get("base_smtp_port", 465)),
|
||||||
|
"smtp_use_tls": acct.get("smtp_use_tls", True),
|
||||||
|
"smtp_username": acct.get("smtp_username", email),
|
||||||
|
"smtp_password": self._resolve_secret_with_vault(acct, "smtp_password"),
|
||||||
|
"imap_host": acct.get("imap_host", config.get("base_imap_host", "")),
|
||||||
|
"imap_port": acct.get("imap_port", config.get("base_imap_port", 993)),
|
||||||
|
"imap_use_ssl": acct.get("imap_use_ssl", True),
|
||||||
|
"imap_username": acct.get("imap_username", email),
|
||||||
|
"imap_password": self._resolve_secret_with_vault(acct, "imap_password"),
|
||||||
|
"idle_timeout_secs": acct.get("idle_timeout_secs", 0),
|
||||||
|
"poll_fallback_secs": acct.get("poll_fallback_secs", 60),
|
||||||
|
"max_fetch_per_cycle": acct.get("max_fetch_per_cycle", 50),
|
||||||
|
"dkim_selector": acct.get("dkim_selector", ""),
|
||||||
|
"dkim_private_key": self._resolve_secret_with_vault(acct, "dkim_private_key"),
|
||||||
|
"dkim_domain": acct.get("dkim_domain", ""),
|
||||||
|
"enabled": acct.get("enabled", True),
|
||||||
|
"dm_policy": acct.get("dm_policy", config.get("default_dm_policy", "open")),
|
||||||
|
"oauth2_enabled": acct.get("oauth2_enabled", False),
|
||||||
|
"oauth2_client_id": acct.get("oauth2_client_id", ""),
|
||||||
|
"oauth2_client_secret": acct.get("oauth2_client_secret", ""),
|
||||||
|
"oauth2_refresh_token": acct.get("oauth2_refresh_token", ""),
|
||||||
|
"oauth2_access_token": acct.get("oauth2_access_token", ""),
|
||||||
|
"oauth2_token_expiry": acct.get("oauth2_token_expiry", 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_configured(self, account: dict) -> bool:
|
||||||
|
return bool(account.get("email_address") and account.get("smtp_host") and account.get("imap_host"))
|
||||||
|
|
||||||
|
def is_enabled(self, account: dict) -> bool:
|
||||||
|
return account.get("enabled", True)
|
||||||
|
|
||||||
|
def disabled_reason(self, account: dict) -> str:
|
||||||
|
if not self.is_enabled(account):
|
||||||
|
return "\u5df2\u7981\u7528"
|
||||||
|
if not self.is_configured(account):
|
||||||
|
return "\u914d\u7f6e\u4e0d\u5b8c\u6574"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def describe_account(self, account: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"account_id": account.get("account_id", ""),
|
||||||
|
"email_address": account.get("email_address", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_secret(acct: dict, key: str) -> str:
|
||||||
|
raw = acct.get(key, "")
|
||||||
|
if not raw:
|
||||||
|
env_var = acct.get(f"{key}_env", "")
|
||||||
|
if env_var:
|
||||||
|
raw = os.getenv(env_var, "")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def _resolve_secret_with_vault(self, acct: dict, key: str) -> str:
|
||||||
|
raw = self._resolve_secret(acct, key)
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
encrypted_key = f"{key}_encrypted"
|
||||||
|
if acct.get(encrypted_key):
|
||||||
|
try:
|
||||||
|
ciphertext = bytes.fromhex(raw)
|
||||||
|
return self._vault.decrypt(ciphertext)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to decrypt %s, using raw value", key)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_email_account(account_dict: dict) -> EmailAccount:
|
||||||
|
smtp = SmtpConfig(
|
||||||
|
host=account_dict.get("smtp_host", ""),
|
||||||
|
port=account_dict.get("smtp_port", 465),
|
||||||
|
use_tls=account_dict.get("smtp_use_tls", True),
|
||||||
|
starttls=account_dict.get("starttls", False),
|
||||||
|
username=account_dict.get("smtp_username", ""),
|
||||||
|
password=account_dict.get("smtp_password", ""),
|
||||||
|
sender_display_name=account_dict.get("display_name", "AI \u5ba2\u670d"),
|
||||||
|
)
|
||||||
|
imap = ImapConfig(
|
||||||
|
host=account_dict.get("imap_host", ""),
|
||||||
|
port=account_dict.get("imap_port", 993),
|
||||||
|
use_ssl=account_dict.get("imap_use_ssl", True),
|
||||||
|
username=account_dict.get("imap_username", ""),
|
||||||
|
password=account_dict.get("imap_password", ""),
|
||||||
|
idle_timeout_secs=account_dict.get("idle_timeout_secs", 0),
|
||||||
|
poll_fallback_secs=account_dict.get("poll_fallback_secs", 60),
|
||||||
|
max_fetch_per_cycle=account_dict.get("max_fetch_per_cycle", 50),
|
||||||
|
)
|
||||||
|
return EmailAccount(
|
||||||
|
account_id=account_dict.get("account_id", "default"),
|
||||||
|
email_address=account_dict.get("email_address", ""),
|
||||||
|
display_name=account_dict.get("display_name", "AI \u5ba2\u670d"),
|
||||||
|
smtp=smtp,
|
||||||
|
imap=imap,
|
||||||
|
dkim_selector=account_dict.get("dkim_selector", ""),
|
||||||
|
dkim_private_key=account_dict.get("dkim_private_key", ""),
|
||||||
|
dkim_domain=account_dict.get("dkim_domain", ""),
|
||||||
|
enabled=account_dict.get("enabled", True),
|
||||||
|
dm_policy=account_dict.get("dm_policy", "open"),
|
||||||
|
oauth2_enabled=account_dict.get("oauth2_enabled", False),
|
||||||
|
oauth2_client_id=account_dict.get("oauth2_client_id", ""),
|
||||||
|
oauth2_client_secret=account_dict.get("oauth2_client_secret", ""),
|
||||||
|
oauth2_refresh_token=account_dict.get("oauth2_refresh_token", ""),
|
||||||
|
oauth2_access_token=account_dict.get("oauth2_access_token", ""),
|
||||||
|
oauth2_token_expiry=account_dict.get("oauth2_token_expiry", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_config(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from yuxi.config import get_channel_config
|
||||||
|
return get_channel_config("email-smtp") or {}
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
40
backend/package/yuxi/channel/extensions/email_smtp/dedupe.py
Normal file
40
backend/package/yuxi/channel/extensions/email_smtp/dedupe.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDeduplicator:
|
||||||
|
def __init__(self, max_entries: int = 100_000, ttl_seconds: int = 259200):
|
||||||
|
self._seen: OrderedDict[str, float] = OrderedDict()
|
||||||
|
self._max_entries = max_entries
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
|
||||||
|
def is_duplicate(self, message_id: str, uid: str = "") -> bool:
|
||||||
|
key = self._make_key(message_id, uid)
|
||||||
|
self._expire()
|
||||||
|
return key in self._seen
|
||||||
|
|
||||||
|
def mark(self, message_id: str, uid: str = ""):
|
||||||
|
key = self._make_key(message_id, uid)
|
||||||
|
self._expire()
|
||||||
|
self._seen[key] = time.monotonic()
|
||||||
|
if len(self._seen) > self._max_entries:
|
||||||
|
self._seen.popitem(last=False)
|
||||||
|
|
||||||
|
def _make_key(self, message_id: str, uid: str) -> str:
|
||||||
|
raw = f"{message_id}|{uid}".strip().lower()
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
def _expire(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
while self._seen:
|
||||||
|
key, ts = next(iter(self._seen.items()))
|
||||||
|
if now - ts > self._ttl:
|
||||||
|
self._seen.popitem(last=False)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
def handle_uidvalidity_change(self):
|
||||||
|
self._seen.clear()
|
||||||
59
backend/package/yuxi/channel/extensions/email_smtp/errors.py
Normal file
59
backend/package/yuxi/channel/extensions/email_smtp/errors.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailSmtpError(Exception):
|
||||||
|
message: str
|
||||||
|
code: str = ""
|
||||||
|
retryable: bool = False
|
||||||
|
permanent: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ImapAuthError(EmailSmtpError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ImapConnectionError(EmailSmtpError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpAuthError(EmailSmtpError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSendError(EmailSmtpError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EmailParseError(EmailSmtpError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
SMTP_ERROR_CLASSIFICATION: dict[int, tuple[str, bool, bool]] = {
|
||||||
|
421: ("SERVICE_UNAVAILABLE", True, False),
|
||||||
|
450: ("MAILBOX_UNAVAILABLE", True, False),
|
||||||
|
451: ("LOCAL_ERROR", True, False),
|
||||||
|
452: ("INSUFFICIENT_STORAGE", True, False),
|
||||||
|
500: ("SYNTAX_ERROR", False, True),
|
||||||
|
501: ("PARAM_SYNTAX_ERROR", False, True),
|
||||||
|
502: ("COMMAND_NOT_IMPLEMENTED", False, True),
|
||||||
|
503: ("BAD_SEQUENCE", False, True),
|
||||||
|
504: ("PARAM_NOT_IMPLEMENTED", False, True),
|
||||||
|
530: ("AUTH_REQUIRED", False, True),
|
||||||
|
550: ("MAILBOX_NOT_FOUND", False, True),
|
||||||
|
551: ("USER_NOT_LOCAL", False, True),
|
||||||
|
552: ("STORAGE_EXCEEDED", False, True),
|
||||||
|
553: ("MAILBOX_NAME_INVALID", False, True),
|
||||||
|
554: ("TRANSACTION_FAILED", False, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_smtp_error(code: int, message: str) -> EmailSmtpError:
|
||||||
|
if code in SMTP_ERROR_CLASSIFICATION:
|
||||||
|
desc, retryable, permanent = SMTP_ERROR_CLASSIFICATION[code]
|
||||||
|
return SmtpSendError(message=f"[{code}] {desc}: {message}", retryable=retryable, permanent=permanent)
|
||||||
|
if 400 <= code < 500:
|
||||||
|
return SmtpSendError(message=f"[{code}] {message}", retryable=True, permanent=False)
|
||||||
|
return SmtpSendError(message=f"[{code}] {message}", retryable=False, permanent=True)
|
||||||
72
backend/package/yuxi/channel/extensions/email_smtp/format.py
Normal file
72
backend/package/yuxi/channel/extensions/email_smtp/format.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html_to_text(html_content: str) -> str:
|
||||||
|
if not html_content:
|
||||||
|
return ""
|
||||||
|
soup = BeautifulSoup(html_content, "lxml")
|
||||||
|
for tag in soup(["style", "script", "nav", "header", "footer"]):
|
||||||
|
tag.decompose()
|
||||||
|
text = soup.get_text(separator="\n")
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_TABLE_STYLE = "background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
|
||||||
|
_EMAIL_FOOTER = "此邮件由 ForcePilot AI 自动生成"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_paragraph_html(text: str) -> str:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = text.replace("\n", "<br>")
|
||||||
|
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||||
|
text = re.sub(
|
||||||
|
r"`([^`]+)`",
|
||||||
|
('<code style="background:#f5f5f5;padding:2px 4px;border-radius:3px;font-family:monospace;">\\1</code>'),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return f'<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;color:#333333;">{text}</p>'
|
||||||
|
|
||||||
|
|
||||||
|
def build_email_html(markdown_text: str) -> str:
|
||||||
|
escaped = html.escape(markdown_text)
|
||||||
|
paragraphs = escaped.split("\n\n")
|
||||||
|
body_html = "".join(_build_paragraph_html(p) for p in paragraphs)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"<!DOCTYPE html>\n"
|
||||||
|
'<html lang="zh-CN">\n'
|
||||||
|
'<head><meta charset="utf-8">'
|
||||||
|
'<meta name="viewport" content="width=device-width"></head>\n'
|
||||||
|
'<body style="margin:0;padding:0;">\n'
|
||||||
|
f'<table width="100%" cellpadding="0" cellspacing="0" style="{_EMAIL_TABLE_STYLE}">\n'
|
||||||
|
" <tr>\n"
|
||||||
|
' <td style="padding:16px 8px;max-width:600px;">\n'
|
||||||
|
f" {body_html}\n"
|
||||||
|
' <hr style="border:none;border-top:1px solid #e8eaed;margin:24px 0 12px 0;">\n'
|
||||||
|
f' <p style="margin:0;font-size:12px;color:#9aa0a6;">{_EMAIL_FOOTER}</p>\n'
|
||||||
|
" </td>\n"
|
||||||
|
" </tr>\n"
|
||||||
|
"</table>\n"
|
||||||
|
"</body>\n"
|
||||||
|
"</html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_email_system_prompt() -> str:
|
||||||
|
return (
|
||||||
|
"你是一个邮件客服 AI。回答时请遵循以下规范:\n"
|
||||||
|
"1. 使用正式但友好的语气,避免过于口语化\n"
|
||||||
|
"2. 不要使用 Markdown 格式(邮件客户端可能不支持)\n"
|
||||||
|
"3. 回复开头不需要问候语,直接回答问题\n"
|
||||||
|
"4. 保持回复简洁,每次聚焦于用户的核心问题\n"
|
||||||
|
"5. 不要使用表情符号\n"
|
||||||
|
"6. 段落之间用空行分隔,使用 **粗体** 强调关键信息"
|
||||||
|
)
|
||||||
157
backend/package/yuxi/channel/extensions/email_smtp/gateway.py
Normal file
157
backend/package/yuxi/channel/extensions/email_smtp/gateway.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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)
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
|
from imap_tools import AND, A, MailBox
|
||||||
|
|
||||||
|
from .charset_utils import decode_mime_header
|
||||||
|
from .mime_parser import parse_mime_email
|
||||||
|
from .types import ParsedEmail
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROVIDER_IDLE_TIMEOUTS: dict[str, int] = {
|
||||||
|
"gmail.com": 25 * 60,
|
||||||
|
"googlemail.com": 25 * 60,
|
||||||
|
"outlook.com": 25 * 60,
|
||||||
|
"office365.com": 25 * 60,
|
||||||
|
"hotmail.com": 25 * 60,
|
||||||
|
"live.com": 25 * 60,
|
||||||
|
"qq.com": 8 * 60,
|
||||||
|
"foxmail.com": 8 * 60,
|
||||||
|
"163.com": 25 * 60,
|
||||||
|
"126.com": 25 * 60,
|
||||||
|
"yeah.net": 25 * 60,
|
||||||
|
"aliyun.com": 25 * 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ImapClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
use_ssl: bool = True,
|
||||||
|
idle_timeout_secs: int = 0,
|
||||||
|
poll_fallback_secs: int = 60,
|
||||||
|
max_fetch_per_cycle: int = 50,
|
||||||
|
):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._use_ssl = use_ssl
|
||||||
|
self._idle_timeout = idle_timeout_secs or self._detect_idle_timeout()
|
||||||
|
self._poll_fallback = poll_fallback_secs
|
||||||
|
self._max_fetch = max_fetch_per_cycle
|
||||||
|
self._mailbox: MailBox | None = None
|
||||||
|
self._last_uid: str = ""
|
||||||
|
self._uid_validity: int = 0
|
||||||
|
|
||||||
|
def _detect_idle_timeout(self) -> int:
|
||||||
|
domain = self._username.split("@")[-1].lower() if "@" in self._username else ""
|
||||||
|
return PROVIDER_IDLE_TIMEOUTS.get(domain, 20 * 60)
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self._mailbox = await asyncio.to_thread(MailBox, self._host, self._port)
|
||||||
|
await asyncio.to_thread(self._mailbox.login, self._username, self._password, initial_folder="INBOX")
|
||||||
|
folder_info = await asyncio.to_thread(self._mailbox.folder.status, "INBOX")
|
||||||
|
self._uid_validity = folder_info.get("UIDVALIDITY", 0)
|
||||||
|
|
||||||
|
async def connect_oauth2(self, access_token: str):
|
||||||
|
from .oauth2 import EmailOAuth2Manager
|
||||||
|
|
||||||
|
xoauth2 = EmailOAuth2Manager.build_xoauth2_string(self._username, access_token)
|
||||||
|
self._mailbox = await asyncio.to_thread(MailBox, self._host, self._port)
|
||||||
|
await asyncio.to_thread(self._mailbox.login, self._username, xoauth2, initial_folder="INBOX")
|
||||||
|
folder_info = await asyncio.to_thread(self._mailbox.folder.status, "INBOX")
|
||||||
|
self._uid_validity = folder_info.get("UIDVALIDITY", 0)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
if self._mailbox:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self._mailbox.logout)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._mailbox = None
|
||||||
|
|
||||||
|
async def append_to_sent(self, raw_message: bytes) -> None:
|
||||||
|
if self._mailbox:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._mailbox.append,
|
||||||
|
raw_message,
|
||||||
|
"Sent",
|
||||||
|
"\\Seen",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to append to Sent folder: %s", e)
|
||||||
|
|
||||||
|
async def listen(self, cancel_event: asyncio.Event):
|
||||||
|
while not cancel_event.is_set():
|
||||||
|
async for email in self._process_backlog():
|
||||||
|
yield email
|
||||||
|
|
||||||
|
try:
|
||||||
|
idle_start = time.monotonic()
|
||||||
|
responses = await asyncio.to_thread(self._mailbox.idle.wait, timeout=min(self._idle_timeout, 300))
|
||||||
|
if responses:
|
||||||
|
async for email in self._process_backlog():
|
||||||
|
yield email
|
||||||
|
elapsed = time.monotonic() - idle_start
|
||||||
|
if elapsed >= self._idle_timeout * 0.9:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
await asyncio.sleep(self._poll_fallback)
|
||||||
|
async for email in self._process_backlog():
|
||||||
|
yield email
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _process_backlog(self):
|
||||||
|
criteria = A(seen=False)
|
||||||
|
if self._last_uid:
|
||||||
|
criteria = AND(seen=False, uid=f"{self._last_uid}:*")
|
||||||
|
|
||||||
|
messages = await asyncio.to_thread(
|
||||||
|
lambda: list(self._mailbox.fetch(criteria, mark_seen=True, limit=self._max_fetch, reverse=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
parsed = await asyncio.to_thread(self._parse_message, msg)
|
||||||
|
if parsed:
|
||||||
|
self._last_uid = msg.uid
|
||||||
|
yield parsed
|
||||||
|
|
||||||
|
def _parse_message(self, msg) -> ParsedEmail | None:
|
||||||
|
try:
|
||||||
|
parsed = parse_mime_email(msg.obj)
|
||||||
|
email = ParsedEmail(
|
||||||
|
message_id=msg.obj.get("Message-ID", "").strip("<>"),
|
||||||
|
uid=msg.uid,
|
||||||
|
uid_validity=self._uid_validity,
|
||||||
|
subject=decode_mime_header(msg.obj.get("Subject", "")),
|
||||||
|
from_display_name=parsed["from_display_name"],
|
||||||
|
from_email=parsed["from_email"],
|
||||||
|
to_list=parsed.get("to_list", []),
|
||||||
|
cc_list=parsed.get("cc_list", []),
|
||||||
|
in_reply_to=msg.obj.get("In-Reply-To", "").strip("<>") or None,
|
||||||
|
references=self._parse_references(msg.obj.get("References", "")),
|
||||||
|
body_text=parsed.get("body_text", ""),
|
||||||
|
body_html=parsed.get("body_html", ""),
|
||||||
|
date=parsedate_to_datetime(msg.obj.get("Date")) if msg.obj.get("Date") else None,
|
||||||
|
attachments=parsed.get("attachments", []),
|
||||||
|
raw_bytes=msg.obj.as_bytes(),
|
||||||
|
gm_thrid=msg.obj.get("X-GM-THRID"),
|
||||||
|
auto_submitted=parsed.get("auto_submitted", ""),
|
||||||
|
)
|
||||||
|
return email
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse email message: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_references(raw: str) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [ref.strip("<>").strip() for ref in raw.split() if ref.strip("<>").strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uid_validity(self) -> int:
|
||||||
|
return self._uid_validity
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import email
|
||||||
|
from email import policy
|
||||||
|
from email.utils import getaddresses, parseaddr
|
||||||
|
|
||||||
|
from .charset_utils import decode_mime_header, safe_decode_payload
|
||||||
|
from .reply_stripper import strip_quotation
|
||||||
|
from .types import EmailAttachment
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mime_email(raw_msg: bytes) -> dict:
|
||||||
|
msg = email.message_from_bytes(raw_msg, policy=policy.default)
|
||||||
|
|
||||||
|
from_display_name, from_email = parseaddr(decode_mime_header(str(msg["From"])))
|
||||||
|
to_list = _parse_address_list(str(msg["To"]))
|
||||||
|
cc_list = _parse_address_list(str(msg["Cc"]))
|
||||||
|
|
||||||
|
body_text = ""
|
||||||
|
body_html = ""
|
||||||
|
attachments: list[EmailAttachment] = []
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
disposition = part.get_content_disposition()
|
||||||
|
|
||||||
|
if content_type == "text/plain" and disposition != "attachment":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
charset = part.get_content_charset()
|
||||||
|
body_text += safe_decode_payload(payload or b"", charset)
|
||||||
|
|
||||||
|
elif content_type == "text/html" and disposition != "attachment":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
charset = part.get_content_charset()
|
||||||
|
body_html += safe_decode_payload(payload or b"", charset)
|
||||||
|
|
||||||
|
elif disposition == "attachment" or (
|
||||||
|
disposition is None and part.get_content_maintype() not in ("text", "multipart")
|
||||||
|
):
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
filename = decode_mime_header(filename)
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
attachments.append(
|
||||||
|
EmailAttachment(
|
||||||
|
filename=filename or "unnamed",
|
||||||
|
content_type=content_type,
|
||||||
|
data=payload or b"",
|
||||||
|
size=len(payload) if payload else 0,
|
||||||
|
content_id=part.get("Content-ID", "").strip("<>"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
charset = msg.get_content_charset()
|
||||||
|
content_type = msg.get_content_type()
|
||||||
|
if content_type == "text/html":
|
||||||
|
body_html = safe_decode_payload(payload or b"", charset)
|
||||||
|
else:
|
||||||
|
body_text = safe_decode_payload(payload or b"", charset)
|
||||||
|
|
||||||
|
if not body_text and body_html:
|
||||||
|
import html2text
|
||||||
|
|
||||||
|
body_text = html2text.html2text(body_html)
|
||||||
|
|
||||||
|
body_text = strip_quotation(body_text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"from_display_name": from_display_name,
|
||||||
|
"from_email": from_email,
|
||||||
|
"to_list": to_list,
|
||||||
|
"cc_list": cc_list,
|
||||||
|
"body_text": body_text.strip(),
|
||||||
|
"body_html": body_html,
|
||||||
|
"attachments": attachments,
|
||||||
|
"auto_submitted": str(msg.get("Auto-Submitted", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_address_list(raw: str) -> list[dict]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
decoded = decode_mime_header(raw)
|
||||||
|
addresses = getaddresses([decoded])
|
||||||
|
return [{"display_name": name.strip() or None, "email": addr.lower().strip()} for name, addr in addresses if addr]
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from yuxi.channel.message.models import PeerInfo, UnifiedMessage
|
||||||
|
from yuxi.channel.routing.models import PeerKind
|
||||||
|
|
||||||
|
from .types import EmailAccount, ParsedEmail
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpMonitor:
|
||||||
|
def parse_to_unified(self, email: ParsedEmail, account: EmailAccount) -> UnifiedMessage:
|
||||||
|
is_group = len(email.to_list) > 1 or len(email.cc_list) > 0
|
||||||
|
|
||||||
|
thread_id = email.in_reply_to or email.message_id
|
||||||
|
if email.references:
|
||||||
|
for ref in reversed(email.references):
|
||||||
|
if ref:
|
||||||
|
thread_id = ref
|
||||||
|
break
|
||||||
|
|
||||||
|
peer = PeerInfo(
|
||||||
|
kind=PeerKind.DIRECT if not is_group else PeerKind.GROUP,
|
||||||
|
id=email.from_email,
|
||||||
|
display_name=email.from_display_name or email.from_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
return UnifiedMessage(
|
||||||
|
msg_id=f"email-smtp:{email.message_id}",
|
||||||
|
channel_type="email-smtp",
|
||||||
|
account_id=account.account_id,
|
||||||
|
sender=peer,
|
||||||
|
content=email.body_text,
|
||||||
|
reply_to_id=email.in_reply_to,
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
media_urls=[att.content_id or att.filename for att in email.attachments],
|
||||||
|
metadata={
|
||||||
|
"subject": email.subject,
|
||||||
|
"to": [t["email"] for t in email.to_list],
|
||||||
|
"cc": [t["email"] for t in email.cc_list],
|
||||||
|
"date": email.date.isoformat() if email.date else None,
|
||||||
|
"gm_thrid": email.gm_thrid,
|
||||||
|
"uid": email.uid,
|
||||||
|
"uid_validity": email.uid_validity,
|
||||||
|
},
|
||||||
|
raw_payload={"raw_bytes": email.raw_bytes},
|
||||||
|
)
|
||||||
61
backend/package/yuxi/channel/extensions/email_smtp/oauth2.py
Normal file
61
backend/package/yuxi/channel/extensions/email_smtp/oauth2.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuth2Token:
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
expires_at: float
|
||||||
|
token_type: str = "Bearer"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return time.time() > self.expires_at - 60
|
||||||
|
|
||||||
|
|
||||||
|
class EmailOAuth2Manager:
|
||||||
|
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||||
|
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
|
||||||
|
def __init__(self, provider: str, client_id: str, client_secret: str):
|
||||||
|
self._provider = provider
|
||||||
|
self._client_id = client_id
|
||||||
|
self._client_secret = client_secret
|
||||||
|
self._token: OAuth2Token | None = None
|
||||||
|
|
||||||
|
def _token_url(self) -> str:
|
||||||
|
if self._provider in ("outlook", "office365", "hotmail", "live"):
|
||||||
|
return self.MICROSOFT_TOKEN_URL
|
||||||
|
return self.GOOGLE_TOKEN_URL
|
||||||
|
|
||||||
|
async def get_access_token(self, refresh_token: str) -> str:
|
||||||
|
if self._token and not self._token.is_expired:
|
||||||
|
return self._token.access_token
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"client_id": self._client_id,
|
||||||
|
"client_secret": self._client_secret,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
self._token_url(), data=data, timeout=aiohttp.ClientTimeout(total=15)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"OAuth2 token refresh failed: {await resp.text()}")
|
||||||
|
token_data = await resp.json()
|
||||||
|
self._token = OAuth2Token(
|
||||||
|
access_token=token_data["access_token"],
|
||||||
|
refresh_token=token_data.get("refresh_token", refresh_token),
|
||||||
|
expires_at=time.time() + token_data.get("expires_in", 3600),
|
||||||
|
)
|
||||||
|
return self._token.access_token
|
||||||
|
|
||||||
|
def build_xoauth2_string(self, username: str, access_token: str) -> str:
|
||||||
|
return f"user={username}\x01auth=Bearer {access_token}\x01\x01"
|
||||||
198
backend/package/yuxi/channel/extensions/email_smtp/outbound.py
Normal file
198
backend/package/yuxi/channel/extensions/email_smtp/outbound.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
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
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpPairingAdapter:
|
||||||
|
id_label = "email_address"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._codes: dict[str, dict] = {}
|
||||||
|
self._code_ttl = 600
|
||||||
|
|
||||||
|
def generate_code(self, peer_id: str) -> str:
|
||||||
|
code = str(random.randint(100000, 999999))
|
||||||
|
self._codes[peer_id] = {"code": code, "ts": time.monotonic()}
|
||||||
|
return code
|
||||||
|
|
||||||
|
def verify_code(self, peer_id: str, code: str) -> bool:
|
||||||
|
entry = self._codes.get(peer_id)
|
||||||
|
if not entry:
|
||||||
|
return False
|
||||||
|
if time.monotonic() - entry["ts"] > self._code_ttl:
|
||||||
|
del self._codes[peer_id]
|
||||||
|
return False
|
||||||
|
return entry["code"] == code
|
||||||
|
|
||||||
|
def normalize_allow_entry(self, entry: str) -> str:
|
||||||
|
return entry.strip().lower()
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "email-smtp",
|
||||||
|
"name": "\u90ae\u4ef6 (SMTP/IMAP)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"label": "Email",
|
||||||
|
"aliases": ["email", "smtp", "imap", "mail"],
|
||||||
|
"description": "SMTP/IMAP \u90ae\u4ef6\u6e20\u9053\u63d2\u4ef6\uff0c\u652f\u6301 IMAP IDLE \u5b9e\u65f6\u6536\u4ef6 + SMTP \u53d1\u4ef6",
|
||||||
|
"author": "ForcePilot",
|
||||||
|
"order": 80,
|
||||||
|
"dependencies": ["aiosmtplib", "imap-tools", "aiohttp", "beautifulsoup4", "html2text", "charset-normalizer"],
|
||||||
|
"enabled": true,
|
||||||
|
"python_requires": ">=3.12",
|
||||||
|
"capabilities": {
|
||||||
|
"chat_types": ["direct"],
|
||||||
|
"message_types": ["text"],
|
||||||
|
"reactions": false,
|
||||||
|
"typing_indicator": false,
|
||||||
|
"threads": true,
|
||||||
|
"edit": false,
|
||||||
|
"unsend": false,
|
||||||
|
"reply": true,
|
||||||
|
"media": true,
|
||||||
|
"native_commands": false,
|
||||||
|
"polls": false,
|
||||||
|
"streaming": true,
|
||||||
|
"streaming_mode": "block",
|
||||||
|
"block_streaming": true,
|
||||||
|
"block_streaming_chunk_min_chars": 800,
|
||||||
|
"block_streaming_chunk_max_chars": 1200,
|
||||||
|
"block_streaming_chunk_break_preference": "paragraph"
|
||||||
|
}
|
||||||
|
}
|
||||||
273
backend/package/yuxi/channel/extensions/email_smtp/plugin.py
Normal file
273
backend/package/yuxi/channel/extensions/email_smtp/plugin.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from yuxi.channel.capabilities import ChannelCapabilities
|
||||||
|
from yuxi.channel.extensions.base import BaseChannelPlugin
|
||||||
|
from yuxi.channel.protocols import SessionResolution
|
||||||
|
|
||||||
|
from .config import EmailSmtpConfigAdapter
|
||||||
|
from .dedupe import EmailDeduplicator
|
||||||
|
from .format import build_email_system_prompt
|
||||||
|
from .gateway import EmailSmtpGatewayAdapter
|
||||||
|
from .outbound import EmailSmtpOutboundAdapter
|
||||||
|
from .pairing import EmailSmtpPairingAdapter
|
||||||
|
from .security import EmailSmtpSecurityAdapter
|
||||||
|
from .session import EmailSmtpSessionAdapter
|
||||||
|
from .status import EmailSmtpStatusAdapter
|
||||||
|
from .streaming import EmailSmtpStreamingAdapter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpPlugin(BaseChannelPlugin):
|
||||||
|
id = "email-smtp"
|
||||||
|
name = "\u90ae\u4ef6 (SMTP/IMAP)"
|
||||||
|
order = 80
|
||||||
|
label = "\u90ae\u4ef6"
|
||||||
|
aliases = ["email", "smtp", "imap", "mail"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._config = EmailSmtpConfigAdapter()
|
||||||
|
self._outbound = EmailSmtpOutboundAdapter()
|
||||||
|
self._dedupe = EmailDeduplicator()
|
||||||
|
self._gateway = EmailSmtpGatewayAdapter(self._outbound, dedupe=self._dedupe)
|
||||||
|
self._security = EmailSmtpSecurityAdapter()
|
||||||
|
self._pairing = EmailSmtpPairingAdapter()
|
||||||
|
self._session = EmailSmtpSessionAdapter()
|
||||||
|
self._streaming = EmailSmtpStreamingAdapter(self._outbound)
|
||||||
|
self._status = EmailSmtpStatusAdapter()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> ChannelCapabilities:
|
||||||
|
return ChannelCapabilities(
|
||||||
|
chat_types=["direct"],
|
||||||
|
message_types=["text"],
|
||||||
|
reactions=False,
|
||||||
|
typing_indicator=False,
|
||||||
|
threads=True,
|
||||||
|
edit=False,
|
||||||
|
unsend=False,
|
||||||
|
reply=True,
|
||||||
|
media=True,
|
||||||
|
native_commands=False,
|
||||||
|
polls=False,
|
||||||
|
streaming=True,
|
||||||
|
streaming_mode="block",
|
||||||
|
block_streaming=True,
|
||||||
|
block_streaming_chunk_min_chars=800,
|
||||||
|
block_streaming_chunk_max_chars=1200,
|
||||||
|
block_streaming_chunk_break_preference="paragraph",
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_account_ids(self, config: dict) -> list[str]:
|
||||||
|
return self._config.list_account_ids(config)
|
||||||
|
|
||||||
|
async def resolve_account(self, account_id: str) -> dict:
|
||||||
|
return self._config.resolve_account(account_id)
|
||||||
|
|
||||||
|
def is_configured(self, account: dict) -> bool:
|
||||||
|
return self._config.is_configured(account)
|
||||||
|
|
||||||
|
def is_enabled(self, account: dict) -> bool:
|
||||||
|
return self._config.is_enabled(account)
|
||||||
|
|
||||||
|
def disabled_reason(self, account: dict) -> str:
|
||||||
|
return self._config.disabled_reason(account)
|
||||||
|
|
||||||
|
def describe_account(self, account: dict) -> dict:
|
||||||
|
return self._config.describe_account(account)
|
||||||
|
|
||||||
|
async def start(self, ctx) -> object:
|
||||||
|
return await self._gateway.start(ctx)
|
||||||
|
|
||||||
|
async def stop(self, ctx) -> None:
|
||||||
|
await self._gateway.stop(ctx)
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
await self._outbound.send_text(
|
||||||
|
target_id,
|
||||||
|
content,
|
||||||
|
reply_to_id=reply_to_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
account_id=account_id,
|
||||||
|
cc_list=cc_list,
|
||||||
|
bcc_list=bcc_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
await self._outbound.send_media(
|
||||||
|
target_id,
|
||||||
|
media_url,
|
||||||
|
media_type,
|
||||||
|
reply_to_id=reply_to_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def probe(self, account: dict) -> bool:
|
||||||
|
return await self._status.probe(account)
|
||||||
|
|
||||||
|
def build_summary(self, snapshot: object) -> dict:
|
||||||
|
return self._status.build_summary(snapshot)
|
||||||
|
|
||||||
|
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
||||||
|
return self._security.check_allowlist(peer_id)
|
||||||
|
|
||||||
|
def resolve_dm_policy(self) -> dict:
|
||||||
|
return self._security.resolve_dm_policy()
|
||||||
|
|
||||||
|
async def generate_code(self, peer_id: str) -> str:
|
||||||
|
return self._pairing.generate_code(peer_id)
|
||||||
|
|
||||||
|
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||||||
|
return self._pairing.verify_code(peer_id, code)
|
||||||
|
|
||||||
|
def normalize_allow_entry(self, entry: str) -> str:
|
||||||
|
return self._pairing.normalize_allow_entry(entry)
|
||||||
|
|
||||||
|
def is_duplicate(self, key: str) -> bool:
|
||||||
|
return self._dedupe.is_duplicate(key)
|
||||||
|
|
||||||
|
def mark_seen(self, key: str) -> None:
|
||||||
|
self._dedupe.mark(key)
|
||||||
|
|
||||||
|
def build_system_prompt(self, context) -> str | None:
|
||||||
|
return build_email_system_prompt()
|
||||||
|
|
||||||
|
def build_context_note(self, context) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def extract_thread_id(self, msg: object) -> str | None:
|
||||||
|
if hasattr(msg, "message_thread_id"):
|
||||||
|
return msg.message_thread_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_session(self, msg: object) -> SessionResolution:
|
||||||
|
from yuxi.channel.message.models import PeerKind
|
||||||
|
|
||||||
|
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
|
||||||
|
if msg.sender.kind == PeerKind.DIRECT:
|
||||||
|
session = self._session.resolve_session(msg)
|
||||||
|
return SessionResolution(
|
||||||
|
kind="direct",
|
||||||
|
conversation_id=msg.sender.id,
|
||||||
|
thread_id=session.thread_id,
|
||||||
|
label=session.label,
|
||||||
|
)
|
||||||
|
gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown"
|
||||||
|
return SessionResolution(kind="group", conversation_id=gid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def streaming_mode(self) -> str:
|
||||||
|
return self._streaming.streaming_mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preview_stream_throttle_ms(self) -> int:
|
||||||
|
return self._streaming.preview_stream_throttle_ms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_streaming_enabled(self) -> bool:
|
||||||
|
return self._streaming.block_streaming_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_streaming_chunk_min_chars(self) -> int:
|
||||||
|
return self._streaming.block_streaming_chunk_min_chars
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_streaming_chunk_max_chars(self) -> int:
|
||||||
|
return self._streaming.block_streaming_chunk_max_chars
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_streaming_chunk_break_preference(self) -> str:
|
||||||
|
return self._streaming.block_streaming_chunk_break_preference
|
||||||
|
|
||||||
|
@property
|
||||||
|
def block_streaming_coalesce_defaults(self) -> dict | None:
|
||||||
|
return self._streaming.block_streaming_coalesce_defaults
|
||||||
|
|
||||||
|
def create_block_chunker(self) -> object:
|
||||||
|
return self._streaming.create_block_chunker()
|
||||||
|
|
||||||
|
def create_draft_stream_session(self, target_id: str) -> object:
|
||||||
|
return self._streaming.create_draft_stream_session(target_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_format_instructions(self) -> str | None:
|
||||||
|
return (
|
||||||
|
"You are sending email replies via SMTP. "
|
||||||
|
"Use plain paragraphs separated by blank lines. "
|
||||||
|
"Use **bold** for emphasis. Do not use Markdown tables or code blocks. "
|
||||||
|
"Each reply should be concise and focused on the user's question. "
|
||||||
|
"Do not include greetings like 'Hello' or 'Dear' at the start. "
|
||||||
|
"Sign-off is optional - the system will add its own footer."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ping_interval_ms(self) -> int:
|
||||||
|
return 5 * 60 * 1000
|
||||||
|
|
||||||
|
async def on_ping(self, account: dict) -> bool:
|
||||||
|
try:
|
||||||
|
imap_client = self._gateway.get_imap_client(account.get("account_id", "default"))
|
||||||
|
if imap_client and imap_client._mailbox:
|
||||||
|
await asyncio.to_thread(imap_client._mailbox.noop)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
|
||||||
|
client = self._gateway.get_imap_client(account_id)
|
||||||
|
if client:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def on_account_removed(self, account_id: str) -> None:
|
||||||
|
client = self._gateway.get_imap_client(account_id)
|
||||||
|
if client:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
async def on_message_received(self, msg: object) -> object | None:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
async def on_message_sending(self, msg: object, content: str) -> str | None:
|
||||||
|
logger.debug("Sending to %s: %s...", getattr(msg, "target_id", "unknown"), content[:100])
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def before_send_attempt(self, target_id: str, content: str) -> None:
|
||||||
|
logger.debug("Before send attempt to %s", target_id)
|
||||||
|
|
||||||
|
async def after_send_success(self, target_id: str, receipt: object) -> None:
|
||||||
|
logger.info("Sent successfully to %s", target_id)
|
||||||
|
|
||||||
|
async def after_send_failure(self, target_id: str, error: str) -> None:
|
||||||
|
logger.error("Send failed to %s: %s", target_id, error)
|
||||||
|
|
||||||
|
def config_schema(self) -> dict:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accounts": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "\u591a\u8d26\u6237\u914d\u7f6e",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from email_reply_parser import EmailReplyParser
|
||||||
|
|
||||||
|
QUOTATION_PATTERNS: list[tuple[re.Pattern, bool]] = [
|
||||||
|
(re.compile(r"^\s*-{2,}\s*reply.above.this.line\s*-{2,}", re.MULTILINE | re.IGNORECASE), True),
|
||||||
|
(re.compile(r"^#{1,3}[-\s]*Please type your reply above this line", re.MULTILINE | re.IGNORECASE), True),
|
||||||
|
(re.compile(r"^-{3,}\s*原始邮件\s*-{3,}", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^-{3,}\s*Original Message\s*-{3,}", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^在\s*\d{4}[年/-]\d{1,2}[月/-]\d{1,2}[日\s]", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^发件人[::].*$", re.MULTILINE), False),
|
||||||
|
(re.compile(r"^On\s.+\s<(.*?)>\s+wrote\s*:", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^On\s.+,\sat\s.+,\s.+\s+wrote\s*:", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^_{20,}", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^From:\s.+[\r\n]+Sent:\s.+[\r\n]+To:\s.+[\r\n]+Subject:", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^Le\s+.*a\s+écrit\s*:", re.MULTILINE), True),
|
||||||
|
(re.compile(r"^Am\s+.*schrieb\s+.*:", re.MULTILINE), True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def strip_quotation(body: str) -> str:
|
||||||
|
if not body or len(body) < 20:
|
||||||
|
return body
|
||||||
|
|
||||||
|
result = EmailReplyParser.parse_reply(body)
|
||||||
|
if _is_adequately_stripped(body, result):
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
for pattern, cut_before in QUOTATION_PATTERNS:
|
||||||
|
match = pattern.search(body)
|
||||||
|
if match:
|
||||||
|
if cut_before:
|
||||||
|
return body[:match.start()].strip()
|
||||||
|
start = match.end()
|
||||||
|
next_match = pattern.search(body, start)
|
||||||
|
if next_match:
|
||||||
|
return body[match.end():next_match.start()].strip()
|
||||||
|
return body[match.end():].strip()
|
||||||
|
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_adequately_stripped(original: str, stripped: str) -> bool:
|
||||||
|
if not stripped or len(stripped) < 5:
|
||||||
|
return False
|
||||||
|
if stripped == original:
|
||||||
|
return False
|
||||||
|
ratio = len(stripped) / max(len(original), 1)
|
||||||
|
if ratio < 0.3:
|
||||||
|
return True
|
||||||
|
if ">" in stripped and len(stripped) > len(original) * 0.5:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpSecurityAdapter:
|
||||||
|
def resolve_dm_policy(self, config: dict | None = None) -> dict:
|
||||||
|
return {"mode": "open", "allow_from": []}
|
||||||
|
|
||||||
|
def check_allowlist(self, sender_email: str) -> bool:
|
||||||
|
config = self._load_config()
|
||||||
|
policy = config.get("dm_policy", "open")
|
||||||
|
if policy == "open":
|
||||||
|
return True
|
||||||
|
if policy == "disabled":
|
||||||
|
return False
|
||||||
|
if policy == "allowlist":
|
||||||
|
allowlist = config.get("allowlist", [])
|
||||||
|
return sender_email.lower() in [a.lower() for a in allowlist]
|
||||||
|
if policy == "pairing":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_config() -> dict:
|
||||||
|
try:
|
||||||
|
from yuxi.config import get_channel_config
|
||||||
|
return get_channel_config("email-smtp") or {}
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from yuxi.channel.message.models import UnifiedMessage
|
||||||
|
from yuxi.channel.protocols import SessionResolution
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpSessionAdapter:
|
||||||
|
def __init__(self):
|
||||||
|
self._thread_sessions: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def resolve_session(self, msg: UnifiedMessage, account_id: str = "") -> SessionResolution:
|
||||||
|
thread_id = msg.message_thread_id or msg.msg_id
|
||||||
|
reply_to_id = msg.reply_to_id
|
||||||
|
sender_id = msg.sender.id if msg.sender else "unknown"
|
||||||
|
|
||||||
|
if thread_id not in self._thread_sessions:
|
||||||
|
session_id = f"email:{account_id}:{thread_id}"
|
||||||
|
self._thread_sessions[thread_id] = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"root_message_id": thread_id,
|
||||||
|
"branch_sessions": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
thread = self._thread_sessions[thread_id]
|
||||||
|
|
||||||
|
if reply_to_id and reply_to_id in thread.get("branch_sessions", {}):
|
||||||
|
session_id = thread["branch_sessions"][reply_to_id]
|
||||||
|
elif reply_to_id and reply_to_id != thread.get("root_message_id"):
|
||||||
|
session_id = f"email:{account_id}:{thread_id}:branch:{reply_to_id}"
|
||||||
|
thread.setdefault("branch_sessions", {})[reply_to_id] = session_id
|
||||||
|
else:
|
||||||
|
session_id = thread["session_id"]
|
||||||
|
|
||||||
|
return SessionResolution(
|
||||||
|
kind="direct",
|
||||||
|
conversation_id=sender_id,
|
||||||
|
thread_id=session_id,
|
||||||
|
label=msg.metadata.get("subject", "") if msg.metadata else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract_thread_id(self, msg: UnifiedMessage) -> str | None:
|
||||||
|
return msg.message_thread_id or msg.msg_id
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
from .types import EmailAccount, OutboundResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self, max_per_hour: int = 60, burst: int = 10):
|
||||||
|
self._rate = max_per_hour / 3600.0
|
||||||
|
self._burst = burst
|
||||||
|
self._tokens = float(burst)
|
||||||
|
self._last_refill = time.monotonic()
|
||||||
|
|
||||||
|
async def acquire(self):
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_refill
|
||||||
|
self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
|
||||||
|
self._last_refill = now
|
||||||
|
if self._tokens >= 1:
|
||||||
|
self._tokens -= 1
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpClientManager:
|
||||||
|
def __init__(self, smtp_config):
|
||||||
|
self._config = smtp_config
|
||||||
|
self._rate_limiter = RateLimiter(max_per_hour=60, burst=10)
|
||||||
|
self._last_send_per_recipient: dict[str, float] = {}
|
||||||
|
self._pool: list[aiosmtplib.SMTP] = []
|
||||||
|
self._pool_size = 3
|
||||||
|
self._pool_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def _acquire_connection(self) -> aiosmtplib.SMTP:
|
||||||
|
async with self._pool_lock:
|
||||||
|
while self._pool:
|
||||||
|
conn = self._pool.pop()
|
||||||
|
try:
|
||||||
|
await conn.noop()
|
||||||
|
return conn
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._config.starttls:
|
||||||
|
conn = aiosmtplib.SMTP(
|
||||||
|
hostname=self._config.host,
|
||||||
|
port=self._config.port,
|
||||||
|
use_tls=False,
|
||||||
|
)
|
||||||
|
await conn.connect()
|
||||||
|
await conn.starttls()
|
||||||
|
else:
|
||||||
|
conn = aiosmtplib.SMTP(
|
||||||
|
hostname=self._config.host,
|
||||||
|
port=self._config.port,
|
||||||
|
use_tls=self._config.use_tls,
|
||||||
|
)
|
||||||
|
await conn.connect()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
async def _release_connection(self, conn: aiosmtplib.SMTP):
|
||||||
|
async with self._pool_lock:
|
||||||
|
if len(self._pool) < self._pool_size:
|
||||||
|
self._pool.append(conn)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await conn.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _authenticate(self, conn: aiosmtplib.SMTP, account: EmailAccount):
|
||||||
|
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)
|
||||||
|
xoauth2 = oauth2_mgr.build_xoauth2_string(account.email_address, access_token)
|
||||||
|
await conn.login(account.email_address, xoauth2)
|
||||||
|
elif self._config.username and self._config.password:
|
||||||
|
await conn.login(self._config.username, self._config.password)
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self, msg: MIMEMultipart, account: EmailAccount, recipients: list[str] | None = None
|
||||||
|
) -> OutboundResult:
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
|
|
||||||
|
to_addr = msg["To"]
|
||||||
|
if to_addr:
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._last_send_per_recipient.get(to_addr, 0)
|
||||||
|
if now - last < 30:
|
||||||
|
await asyncio.sleep(30 - (now - last))
|
||||||
|
self._last_send_per_recipient[to_addr] = time.monotonic()
|
||||||
|
|
||||||
|
if account.dkim_private_key and account.dkim_selector and account.dkim_domain:
|
||||||
|
msg = self._dkim_sign(msg, account)
|
||||||
|
|
||||||
|
conn = await self._acquire_connection()
|
||||||
|
try:
|
||||||
|
await self._authenticate(conn, account)
|
||||||
|
result = await conn.send_message(msg, recipients=recipients)
|
||||||
|
queue_id = result.get("message", "").split()[-1] if result.get("message") else None
|
||||||
|
|
||||||
|
return OutboundResult(
|
||||||
|
success=True,
|
||||||
|
message_id=msg["Message-ID"],
|
||||||
|
queue_id=queue_id,
|
||||||
|
)
|
||||||
|
except aiosmtplib.SMTPAuthenticationError as e:
|
||||||
|
return OutboundResult(success=False, error=str(e), retryable=False)
|
||||||
|
except aiosmtplib.SMTPConnectError as e:
|
||||||
|
return OutboundResult(success=False, error=str(e), retryable=True)
|
||||||
|
except aiosmtplib.SMTPResponseException as e:
|
||||||
|
retryable = 400 <= e.code < 500
|
||||||
|
return OutboundResult(success=False, error=str(e), retryable=retryable)
|
||||||
|
except Exception as e:
|
||||||
|
return OutboundResult(success=False, error=str(e), retryable=True)
|
||||||
|
finally:
|
||||||
|
await self._release_connection(conn)
|
||||||
|
|
||||||
|
def _dkim_sign(self, msg: MIMEMultipart, account: EmailAccount) -> MIMEMultipart:
|
||||||
|
try:
|
||||||
|
import dkim
|
||||||
|
|
||||||
|
msg_bytes = msg.as_bytes()
|
||||||
|
sig = dkim.sign(
|
||||||
|
message=msg_bytes,
|
||||||
|
selector=account.dkim_selector.encode(),
|
||||||
|
domain=account.dkim_domain.encode(),
|
||||||
|
privkey=account.dkim_private_key.encode(),
|
||||||
|
include_headers=[
|
||||||
|
b"from",
|
||||||
|
b"to",
|
||||||
|
b"subject",
|
||||||
|
b"date",
|
||||||
|
b"message-id",
|
||||||
|
b"in-reply-to",
|
||||||
|
b"references",
|
||||||
|
],
|
||||||
|
canonicalize=(b"relaxed", b"relaxed"),
|
||||||
|
)
|
||||||
|
signed_bytes = sig + msg_bytes
|
||||||
|
from email import message_from_bytes, policy
|
||||||
|
|
||||||
|
return message_from_bytes(signed_bytes, policy=policy.default)
|
||||||
|
except Exception:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
async with self._pool_lock:
|
||||||
|
for conn in self._pool:
|
||||||
|
try:
|
||||||
|
await conn.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._pool.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_smtp(host: str, port: int, use_tls: bool = True) -> bool:
|
||||||
|
smtp = aiosmtplib.SMTP(hostname=host, port=port, use_tls=use_tls)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(smtp.connect(), timeout=10.0)
|
||||||
|
await smtp.ehlo()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
64
backend/package/yuxi/channel/extensions/email_smtp/status.py
Normal file
64
backend/package/yuxi/channel/extensions/email_smtp/status.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
|
|
||||||
|
from .config import EmailSmtpConfigAdapter
|
||||||
|
from .smtp_client import probe_smtp
|
||||||
|
from .types import EmailAccount
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpStatusAdapter:
|
||||||
|
def __init__(self):
|
||||||
|
self._config_adapter = EmailSmtpConfigAdapter()
|
||||||
|
self._last_probe: dict[str, dict] = {}
|
||||||
|
|
||||||
|
async def probe(self, account: dict) -> bool:
|
||||||
|
account_dict = self._config_adapter.resolve_account(account.get("account_id", "default"))
|
||||||
|
email_acct = EmailSmtpConfigAdapter.make_email_account(account_dict)
|
||||||
|
|
||||||
|
smtp_ok = await probe_smtp(email_acct.smtp.host, email_acct.smtp.port, email_acct.smtp.use_tls)
|
||||||
|
imap_ok = await self._probe_imap(email_acct)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "ok" if (smtp_ok and imap_ok) else ("degraded" if (smtp_ok or imap_ok) else "down"),
|
||||||
|
"smtp_connected": smtp_ok,
|
||||||
|
"imap_connected": imap_ok,
|
||||||
|
"probed_at": datetime.now(UTC).isoformat(),
|
||||||
|
"account_id": account.get("account_id", "default"),
|
||||||
|
}
|
||||||
|
self._last_probe[account.get("account_id", "default")] = result
|
||||||
|
return smtp_ok and imap_ok
|
||||||
|
|
||||||
|
def build_summary(self, snapshot: object) -> dict:
|
||||||
|
account_id = getattr(snapshot, "account_id", "default")
|
||||||
|
result = self._last_probe.get(account_id, {})
|
||||||
|
return {
|
||||||
|
"channel_type": "email-smtp",
|
||||||
|
"account_id": account_id,
|
||||||
|
"status": result.get("status", "unknown"),
|
||||||
|
"smtp_ok": result.get("smtp_connected", False),
|
||||||
|
"imap_ok": result.get("imap_connected", False),
|
||||||
|
"last_probe": result.get("probed_at", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _probe_imap(self, account: EmailAccount) -> bool:
|
||||||
|
from imap_tools import MailBox
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
def _check():
|
||||||
|
with MailBox(account.imap.host, account.imap.port).login(
|
||||||
|
account.imap.username, account.imap.password, initial_folder="INBOX"
|
||||||
|
) as mb:
|
||||||
|
status = mb.folder.status("INBOX")
|
||||||
|
return status is not None
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_check)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("IMAP probe failed: %s", e)
|
||||||
|
return False
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
from .outbound import EmailSmtpOutboundAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSmtpStreamingAdapter:
|
||||||
|
streaming_mode = "block"
|
||||||
|
preview_stream_throttle_ms = 160
|
||||||
|
preview_min_initial_chars = 18
|
||||||
|
block_streaming_enabled = True
|
||||||
|
block_streaming_break = "text_end"
|
||||||
|
block_streaming_chunk_min_chars = 800
|
||||||
|
block_streaming_chunk_max_chars = 1200
|
||||||
|
block_streaming_chunk_break_preference = "paragraph"
|
||||||
|
block_streaming_coalesce_defaults = None
|
||||||
|
|
||||||
|
def __init__(self, outbound: EmailSmtpOutboundAdapter | None = None):
|
||||||
|
self._outbound = outbound or EmailSmtpOutboundAdapter()
|
||||||
|
self._idle_ms = 800
|
||||||
|
|
||||||
|
def create_block_chunker(self) -> BlockChunker:
|
||||||
|
return BlockChunker(
|
||||||
|
min_chars=self.block_streaming_chunk_min_chars,
|
||||||
|
max_chars=self.block_streaming_chunk_max_chars,
|
||||||
|
break_preference=self.block_streaming_chunk_break_preference,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_draft_stream_session(self, target_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"target_id": target_id,
|
||||||
|
"accumulated": "",
|
||||||
|
"chunks_sent": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BlockChunker:
|
||||||
|
def __init__(self, min_chars: int = 800, max_chars: int = 1200, break_preference: str = "paragraph"):
|
||||||
|
self._min_chars = min_chars
|
||||||
|
self._max_chars = max_chars
|
||||||
|
self._break_preference = break_preference
|
||||||
|
|
||||||
|
def chunk(self, text: str) -> list[str]:
|
||||||
|
if len(text) <= self._max_chars:
|
||||||
|
return [text] if text else []
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
remaining = text
|
||||||
|
while len(remaining) > self._max_chars:
|
||||||
|
split_at = self._max_chars
|
||||||
|
if self._break_preference == "paragraph":
|
||||||
|
para_break = remaining.rfind("\n\n", 0, self._max_chars)
|
||||||
|
if para_break > self._min_chars:
|
||||||
|
split_at = para_break + 2
|
||||||
|
else:
|
||||||
|
line_break = remaining.rfind("\n", 0, self._max_chars)
|
||||||
|
if line_break > self._min_chars:
|
||||||
|
split_at = line_break + 1
|
||||||
|
|
||||||
|
chunks.append(remaining[:split_at].strip())
|
||||||
|
remaining = remaining[split_at:].strip()
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
chunks.append(remaining)
|
||||||
|
|
||||||
|
return chunks
|
||||||
106
backend/package/yuxi/channel/extensions/email_smtp/types.py
Normal file
106
backend/package/yuxi/channel/extensions/email_smtp/types.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmtpConfig:
|
||||||
|
host: str
|
||||||
|
port: int = 465
|
||||||
|
use_tls: bool = True
|
||||||
|
starttls: bool = False
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
sender_display_name: str = "AI \u5ba2\u670d"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImapConfig:
|
||||||
|
host: str
|
||||||
|
port: int = 993
|
||||||
|
use_ssl: bool = True
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
idle_timeout_secs: int = 0
|
||||||
|
poll_fallback_secs: int = 60
|
||||||
|
max_fetch_per_cycle: int = 50
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailAccount:
|
||||||
|
account_id: str
|
||||||
|
email_address: str
|
||||||
|
display_name: str
|
||||||
|
smtp: SmtpConfig = field(default_factory=lambda: SmtpConfig(""))
|
||||||
|
imap: ImapConfig = field(default_factory=lambda: ImapConfig(""))
|
||||||
|
dkim_selector: str = ""
|
||||||
|
dkim_private_key: str = ""
|
||||||
|
dkim_domain: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
dm_policy: str = "open"
|
||||||
|
oauth2_enabled: bool = False
|
||||||
|
oauth2_client_id: str = ""
|
||||||
|
oauth2_client_secret: str = ""
|
||||||
|
oauth2_refresh_token: str = ""
|
||||||
|
oauth2_access_token: str = ""
|
||||||
|
oauth2_token_expiry: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedEmail:
|
||||||
|
message_id: str
|
||||||
|
uid: str
|
||||||
|
uid_validity: int
|
||||||
|
subject: str
|
||||||
|
from_display_name: str
|
||||||
|
from_email: str
|
||||||
|
to_list: list[dict] = field(default_factory=list)
|
||||||
|
cc_list: list[dict] = field(default_factory=list)
|
||||||
|
in_reply_to: str | None = None
|
||||||
|
references: list[str] = field(default_factory=list)
|
||||||
|
body_text: str = ""
|
||||||
|
body_html: str = ""
|
||||||
|
date: datetime | None = None
|
||||||
|
attachments: list[EmailAttachment] = field(default_factory=list)
|
||||||
|
raw_bytes: bytes = b""
|
||||||
|
gm_thrid: str | None = None
|
||||||
|
auto_submitted: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailAttachment:
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
data: bytes
|
||||||
|
size: int
|
||||||
|
content_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutboundResult:
|
||||||
|
success: bool
|
||||||
|
message_id: str | None = None
|
||||||
|
queue_id: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
retryable: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImapConnectionState:
|
||||||
|
connected: bool = False
|
||||||
|
uid_validity: int = 0
|
||||||
|
last_uid: str = ""
|
||||||
|
last_idle_start: float = 0.0
|
||||||
|
reconnect_count: int = 0
|
||||||
|
last_error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThreadNode:
|
||||||
|
message_id: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
root_id: str | None = None
|
||||||
|
depth: int = 0
|
||||||
|
session_id: str | None = None
|
||||||
|
gm_thrid: str | None = None
|
||||||
Loading…
Reference in New Issue
Block a user