ForcePilot/backend/package/yuxi/channel/extensions/email_smtp/config.py
Kris 59c6caaa64 feat(email-smtp): 新增SMTP/IMAP邮件渠道插件
实现完整的邮件收发渠道,支持IMAP IDLE实时收信、SMTP发信,包含附件校验、重复消息去重、OAuth2认证、邮件内容解析与引用剥离、邮件发送限流与连接池等功能
2026-05-21 10:45:56 +08:00

174 lines
7.4 KiB
Python

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