174 lines
7.4 KiB
Python
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 {}
|