""" inbox/accounts.py — CRUD for email_accounts table. Passwords are encrypted with AES-256-GCM (same scheme as credential_store). """ from __future__ import annotations import json import uuid from datetime import datetime, timezone from typing import Any from ..database import _encrypt, _decrypt, get_pool, _rowcount def _now() -> str: return datetime.now(timezone.utc).isoformat() # ── Read ────────────────────────────────────────────────────────────────────── async def list_accounts(user_id: str | None = None) -> list[dict]: """ List email accounts with decrypted passwords. - user_id=None: all accounts (admin view) - user_id="": accounts for this user only """ pool = await get_pool() if user_id is None: rows = await pool.fetch( "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " ORDER BY ea.created_at" ) else: rows = await pool.fetch( "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.user_id = $1 ORDER BY ea.created_at", user_id, ) return [_decrypt_row(dict(r)) for r in rows] async def list_accounts_enabled() -> list[dict]: """Return all enabled accounts (used by listener on startup).""" pool = await get_pool() rows = await pool.fetch( "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.enabled = TRUE ORDER BY ea.created_at" ) return [_decrypt_row(dict(r)) for r in rows] async def get_account(account_id: str) -> dict | None: pool = await get_pool() row = await pool.fetchrow( "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.id = $1", account_id, ) if row is None: return None return _decrypt_row(dict(row)) # ── Write ───────────────────────────────────────────────────────────────────── async def create_account( label: str, account_type: str, imap_host: str, imap_port: int, imap_username: str, imap_password: str, smtp_host: str | None = None, smtp_port: int | None = None, smtp_username: str | None = None, smtp_password: str | None = None, agent_id: str | None = None, user_id: str | None = None, initial_load_limit: int = 200, monitored_folders: list[str] | None = None, extra_tools: list[str] | None = None, telegram_chat_id: str | None = None, telegram_keyword: str | None = None, enabled: bool = True, ) -> dict: now = _now() account_id = str(uuid.uuid4()) folders_json = json.dumps(monitored_folders or ["INBOX"]) extra_tools_json = json.dumps(extra_tools or []) pool = await get_pool() await pool.execute( """ INSERT INTO email_accounts ( id, user_id, label, account_type, imap_host, imap_port, imap_username, imap_password, smtp_host, smtp_port, smtp_username, smtp_password, agent_id, enabled, initial_load_done, initial_load_limit, monitored_folders, extra_tools, telegram_chat_id, telegram_keyword, paused, created_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) """, account_id, user_id, label, account_type, imap_host, int(imap_port), imap_username, _encrypt(imap_password), smtp_host, int(smtp_port) if smtp_port else None, smtp_username, _encrypt(smtp_password) if smtp_password else None, agent_id, enabled, False, int(initial_load_limit), folders_json, extra_tools_json, telegram_chat_id or None, (telegram_keyword or "").lower().strip() or None, False, now, now, ) return await get_account(account_id) async def update_account(account_id: str, **fields) -> bool: """Update fields. Encrypts imap_password/smtp_password if provided.""" fields["updated_at"] = _now() if "imap_password" in fields: if fields["imap_password"]: fields["imap_password"] = _encrypt(fields["imap_password"]) else: del fields["imap_password"] # don't clear on empty string if "smtp_password" in fields: if fields["smtp_password"]: fields["smtp_password"] = _encrypt(fields["smtp_password"]) else: del fields["smtp_password"] if "monitored_folders" in fields and isinstance(fields["monitored_folders"], list): fields["monitored_folders"] = json.dumps(fields["monitored_folders"]) if "extra_tools" in fields and isinstance(fields["extra_tools"], list): fields["extra_tools"] = json.dumps(fields["extra_tools"]) if "telegram_keyword" in fields and fields["telegram_keyword"]: fields["telegram_keyword"] = fields["telegram_keyword"].lower().strip() or None if "imap_port" in fields and fields["imap_port"] is not None: fields["imap_port"] = int(fields["imap_port"]) if "smtp_port" in fields and fields["smtp_port"] is not None: fields["smtp_port"] = int(fields["smtp_port"]) set_parts = [] values: list[Any] = [] for i, (k, v) in enumerate(fields.items(), start=1): set_parts.append(f"{k} = ${i}") values.append(v) id_param = len(fields) + 1 values.append(account_id) pool = await get_pool() status = await pool.execute( f"UPDATE email_accounts SET {', '.join(set_parts)} WHERE id = ${id_param}", *values, ) return _rowcount(status) > 0 async def delete_account(account_id: str) -> bool: pool = await get_pool() status = await pool.execute("DELETE FROM email_accounts WHERE id = $1", account_id) return _rowcount(status) > 0 async def pause_account(account_id: str) -> bool: pool = await get_pool() await pool.execute( "UPDATE email_accounts SET paused = TRUE, updated_at = $1 WHERE id = $2", _now(), account_id, ) return True async def resume_account(account_id: str) -> bool: pool = await get_pool() await pool.execute( "UPDATE email_accounts SET paused = FALSE, updated_at = $1 WHERE id = $2", _now(), account_id, ) return True async def toggle_account(account_id: str) -> bool: pool = await get_pool() await pool.execute( "UPDATE email_accounts SET enabled = NOT enabled, updated_at = $1 WHERE id = $2", _now(), account_id, ) return True async def mark_initial_load_done(account_id: str) -> None: pool = await get_pool() await pool.execute( "UPDATE email_accounts SET initial_load_done = TRUE, updated_at = $1 WHERE id = $2", _now(), account_id, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _decrypt_row(row: dict) -> dict: """Decrypt password fields in-place. Safe to call on any email_accounts row.""" if row.get("imap_password"): try: row["imap_password"] = _decrypt(row["imap_password"]) except Exception: row["imap_password"] = "" if row.get("smtp_password"): try: row["smtp_password"] = _decrypt(row["smtp_password"]) except Exception: row["smtp_password"] = None if row.get("monitored_folders") and isinstance(row["monitored_folders"], str): try: row["monitored_folders"] = json.loads(row["monitored_folders"]) except Exception: row["monitored_folders"] = ["INBOX"] if isinstance(row.get("extra_tools"), str): try: row["extra_tools"] = json.loads(row["extra_tools"]) except Exception: row["extra_tools"] = [] elif row.get("extra_tools") is None: row["extra_tools"] = [] # Convert UUID to str for JSON serialisation if row.get("id") and not isinstance(row["id"], str): row["id"] = str(row["id"]) return row def mask_account(account: dict) -> dict: """Return a copy safe for the API response — passwords replaced with booleans.""" m = dict(account) m["imap_password"] = bool(account.get("imap_password")) m["smtp_password"] = bool(account.get("smtp_password")) return m