feat(email-smtp): 新增SMTP/IMAP邮件渠道插件

实现完整的邮件收发渠道,支持IMAP IDLE实时收信、SMTP发信,包含附件校验、重复消息去重、OAuth2认证、邮件内容解析与引用剥离、邮件发送限流与连接池等功能
This commit is contained in:
Kris 2026-05-21 10:45:56 +08:00
parent 4552bde837
commit 59c6caaa64
24 changed files with 2314 additions and 0 deletions

View File

@ -0,0 +1,4 @@
from yuxi.channel.extensions.email_smtp.plugin import EmailSmtpPlugin
from yuxi.channel.plugins.registry import ChannelPluginRegistry
ChannelPluginRegistry.register(EmailSmtpPlugin())

View File

@ -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"

View File

@ -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

View File

@ -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")

View 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 {}

View 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()

View 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)

View 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. 段落之间用空行分隔,使用 **粗体** 强调关键信息"
)

View 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)

View File

@ -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

View File

@ -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]

View File

@ -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},
)

View 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"

View 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

View File

@ -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()

View File

@ -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"
}
}

View 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",
},
},
}

View File

@ -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

View File

@ -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 {}

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View 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