diff --git a/backend/package/yuxi/channel/extensions/email_smtp/__init__.py b/backend/package/yuxi/channel/extensions/email_smtp/__init__.py new file mode 100644 index 00000000..a6ed15c2 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/__init__.py @@ -0,0 +1,4 @@ +from yuxi.channel.extensions.email_smtp.plugin import EmailSmtpPlugin +from yuxi.channel.plugins.registry import ChannelPluginRegistry + +ChannelPluginRegistry.register(EmailSmtpPlugin()) diff --git a/backend/package/yuxi/channel/extensions/email_smtp/attachment.py b/backend/package/yuxi/channel/extensions/email_smtp/attachment.py new file mode 100644 index 00000000..3bd7f940 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/attachment.py @@ -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" \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/email_smtp/bounce_handler.py b/backend/package/yuxi/channel/extensions/email_smtp/bounce_handler.py new file mode 100644 index 00000000..2ccaccc8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/bounce_handler.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/charset_utils.py b/backend/package/yuxi/channel/extensions/email_smtp/charset_utils.py new file mode 100644 index 00000000..78f7f1f6 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/charset_utils.py @@ -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") diff --git a/backend/package/yuxi/channel/extensions/email_smtp/config.py b/backend/package/yuxi/channel/extensions/email_smtp/config.py new file mode 100644 index 00000000..3c4afe70 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/config.py @@ -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 {} diff --git a/backend/package/yuxi/channel/extensions/email_smtp/dedupe.py b/backend/package/yuxi/channel/extensions/email_smtp/dedupe.py new file mode 100644 index 00000000..4ae61201 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/dedupe.py @@ -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() diff --git a/backend/package/yuxi/channel/extensions/email_smtp/errors.py b/backend/package/yuxi/channel/extensions/email_smtp/errors.py new file mode 100644 index 00000000..0adf48a8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/errors.py @@ -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) diff --git a/backend/package/yuxi/channel/extensions/email_smtp/format.py b/backend/package/yuxi/channel/extensions/email_smtp/format.py new file mode 100644 index 00000000..4a039dd8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/format.py @@ -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", "
") + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub( + r"`([^`]+)`", + ('\\1'), + text, + ) + return f'

{text}

' + + +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 ( + "\n" + '\n' + '' + '\n' + '\n' + f'\n' + " \n" + ' \n" + " \n" + "
\n' + f" {body_html}\n" + '
\n' + f'

{_EMAIL_FOOTER}

\n' + "
\n" + "\n" + "" + ) + + +def build_email_system_prompt() -> str: + return ( + "你是一个邮件客服 AI。回答时请遵循以下规范:\n" + "1. 使用正式但友好的语气,避免过于口语化\n" + "2. 不要使用 Markdown 格式(邮件客户端可能不支持)\n" + "3. 回复开头不需要问候语,直接回答问题\n" + "4. 保持回复简洁,每次聚焦于用户的核心问题\n" + "5. 不要使用表情符号\n" + "6. 段落之间用空行分隔,使用 **粗体** 强调关键信息" + ) diff --git a/backend/package/yuxi/channel/extensions/email_smtp/gateway.py b/backend/package/yuxi/channel/extensions/email_smtp/gateway.py new file mode 100644 index 00000000..d026c5a3 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/gateway.py @@ -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) diff --git a/backend/package/yuxi/channel/extensions/email_smtp/imap_client.py b/backend/package/yuxi/channel/extensions/email_smtp/imap_client.py new file mode 100644 index 00000000..7c5b429a --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/imap_client.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/mime_parser.py b/backend/package/yuxi/channel/extensions/email_smtp/mime_parser.py new file mode 100644 index 00000000..0562b3a9 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/mime_parser.py @@ -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] diff --git a/backend/package/yuxi/channel/extensions/email_smtp/monitor.py b/backend/package/yuxi/channel/extensions/email_smtp/monitor.py new file mode 100644 index 00000000..275580af --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/monitor.py @@ -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}, + ) diff --git a/backend/package/yuxi/channel/extensions/email_smtp/oauth2.py b/backend/package/yuxi/channel/extensions/email_smtp/oauth2.py new file mode 100644 index 00000000..9cc4d98f --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/oauth2.py @@ -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" diff --git a/backend/package/yuxi/channel/extensions/email_smtp/outbound.py b/backend/package/yuxi/channel/extensions/email_smtp/outbound.py new file mode 100644 index 00000000..0d9c3a3f --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/outbound.py @@ -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 = ( + '
--reply-above-this--
' +) + + +class EmailSmtpOutboundAdapter: + def __init__(self, smtp_managers: dict | None = None, imap_clients: dict | None = None): + self._smtp_managers: dict = smtp_managers or {} + self._imap_clients: dict = imap_clients or {} + self._config_adapter = EmailSmtpConfigAdapter() + self._last_message_metadata: dict = {} + + def set_smtp_managers(self, managers: dict): + self._smtp_managers = managers + + def set_imap_clients(self, clients: dict): + self._imap_clients = clients + + def set_last_message_metadata(self, metadata: dict): + self._last_message_metadata = metadata + + async def send_text( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + cc_list: list[str] | None = None, + bcc_list: list[str] | None = None, + priority: str | None = None, + ) -> None: + aid = account_id or "default" + account_dict = self._config_adapter.resolve_account(aid) + account = EmailSmtpConfigAdapter.make_email_account(account_dict) + + smtp_mgr = self._smtp_managers.get(aid) + if not smtp_mgr: + logger.error("SMTP manager not found for account %s", aid) + return + + html_body = build_email_html(content) + msg_id = make_msgid(domain=account.email_address.split("@")[-1]) + + msg = MIMEMultipart("alternative") + msg["From"] = formataddr((account.display_name, account.email_address)) + msg["To"] = target_id + if cc_list: + msg["Cc"] = ", ".join(cc_list) + msg["Subject"] = self._resolve_subject() + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = msg_id + msg["Auto-Submitted"] = "auto-replied" + msg["X-Mailer"] = "ForcePilot AI/1.0" + + if priority: + self._set_priority_headers(msg, priority) + + if reply_to_id: + msg["In-Reply-To"] = f"<{reply_to_id}>" + msg["References"] = f"<{reply_to_id}>" + + msg.attach(MIMEText(content, "plain", "utf-8")) + msg.attach(MIMEText(html_body + REPLY_SEPARATOR_HTML, "html", "utf-8")) + + all_recipients = [target_id] + if cc_list: + all_recipients.extend(cc_list) + if bcc_list: + all_recipients.extend(bcc_list) + + result = await smtp_mgr.send_message(msg, account, recipients=all_recipients) + if result.success: + try: + imap_client = self._imap_clients.get(aid) + if imap_client: + await imap_client.append_to_sent(msg.as_bytes()) + except Exception: + logger.warning("Failed to append to Sent folder", exc_info=True) + else: + logger.error("SMTP send failed: %s", result.error) + + async def send_media( + self, + target_id: str, + media_url: str, + media_type: str, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: + aid = account_id or "default" + account_dict = self._config_adapter.resolve_account(aid) + account = EmailSmtpConfigAdapter.make_email_account(account_dict) + + smtp_mgr = self._smtp_managers.get(aid) + if not smtp_mgr: + logger.error("SMTP manager not found for account %s", aid) + return + + content_type_map = { + "image": ("image/png", ".png"), + "file": ("application/octet-stream", ""), + "pdf": ("application/pdf", ".pdf"), + } + mime_main, default_ext = content_type_map.get(media_type, ("application/octet-stream", "")) + + try: + async with aiohttp.ClientSession() as session: + async with session.get(media_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + logger.error("Failed to fetch media from %s: HTTP %s", media_url, resp.status) + return + file_data = await resp.read() + except Exception as e: + logger.error("Failed to download media from %s: %s", media_url, e) + return + + filename = os.path.basename(media_url) or f"attachment{default_ext}" + safe, reason = is_attachment_safe( + filename=filename, + content_type=mime_main, + size=len(file_data), + ) + if not safe: + logger.warning("Media rejected by safety filter: %s", reason) + return + + msg = MIMEMultipart("mixed") + msg["From"] = formataddr((account.display_name, account.email_address)) + msg["To"] = target_id + msg["Subject"] = self._resolve_subject() + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=account.email_address.split("@")[-1]) + msg["Auto-Submitted"] = "auto-replied" + msg["X-Mailer"] = "ForcePilot AI/1.0" + + if reply_to_id: + msg["In-Reply-To"] = f"<{reply_to_id}>" + msg["References"] = f"<{reply_to_id}>" + + body = MIMEMultipart("alternative") + plain_content = f"[附件] {media_type}" + body.attach(MIMEText(plain_content, "plain", "utf-8")) + msg.attach(body) + + attachment_part = MIMEBase(*mime_main.split("/", 1)) + attachment_part.set_payload(file_data) + encoders.encode_base64(attachment_part) + attachment_part.add_header("Content-Disposition", "attachment", filename=filename) + msg.attach(attachment_part) + + result = await smtp_mgr.send_message(msg, account) + if result.success: + try: + imap_client = self._imap_clients.get(aid) + if imap_client: + await imap_client.append_to_sent(msg.as_bytes()) + except Exception: + logger.warning("Failed to append media to Sent folder", exc_info=True) + else: + logger.error("SMTP media send failed: %s", result.error) + + def _resolve_subject(self) -> str: + original_subject = self._last_message_metadata.get("subject", "") + if original_subject and not original_subject.lower().startswith("re:"): + return f"Re: {original_subject}" + return original_subject or "Re: \u6765\u81ea AI \u5ba2\u670d\u7684\u56de\u590d" + + @staticmethod + def _set_priority_headers(msg: MIMEMultipart, priority: str) -> None: + priority_map = { + "high": {"X-Priority": "1", "X-MSMail-Priority": "High", "Importance": "high"}, + "normal": {"X-Priority": "3", "X-MSMail-Priority": "Normal", "Importance": "normal"}, + "low": {"X-Priority": "5", "X-MSMail-Priority": "Low", "Importance": "low"}, + } + headers = priority_map.get(priority.lower(), priority_map["normal"]) + for key, value in headers.items(): + msg[key] = value diff --git a/backend/package/yuxi/channel/extensions/email_smtp/pairing.py b/backend/package/yuxi/channel/extensions/email_smtp/pairing.py new file mode 100644 index 00000000..65544f21 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/pairing.py @@ -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() \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/email_smtp/plugin.json b/backend/package/yuxi/channel/extensions/email_smtp/plugin.json new file mode 100644 index 00000000..463c4ca4 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/plugin.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/email_smtp/plugin.py b/backend/package/yuxi/channel/extensions/email_smtp/plugin.py new file mode 100644 index 00000000..9bb7bc4b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/plugin.py @@ -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", + }, + }, + } diff --git a/backend/package/yuxi/channel/extensions/email_smtp/reply_stripper.py b/backend/package/yuxi/channel/extensions/email_smtp/reply_stripper.py new file mode 100644 index 00000000..12049553 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/reply_stripper.py @@ -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 \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/email_smtp/security.py b/backend/package/yuxi/channel/extensions/email_smtp/security.py new file mode 100644 index 00000000..ea72675d --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/security.py @@ -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 {} diff --git a/backend/package/yuxi/channel/extensions/email_smtp/session.py b/backend/package/yuxi/channel/extensions/email_smtp/session.py new file mode 100644 index 00000000..e7d1c352 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/session.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/smtp_client.py b/backend/package/yuxi/channel/extensions/email_smtp/smtp_client.py new file mode 100644 index 00000000..7b8b0331 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/smtp_client.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/status.py b/backend/package/yuxi/channel/extensions/email_smtp/status.py new file mode 100644 index 00000000..8eaeb8a9 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/status.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/streaming.py b/backend/package/yuxi/channel/extensions/email_smtp/streaming.py new file mode 100644 index 00000000..0d530001 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/streaming.py @@ -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 diff --git a/backend/package/yuxi/channel/extensions/email_smtp/types.py b/backend/package/yuxi/channel/extensions/email_smtp/types.py new file mode 100644 index 00000000..79d35fa2 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/email_smtp/types.py @@ -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