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