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