Files
oai-web/server/inbox/accounts.py
2026-04-08 12:43:24 +02:00

247 lines
8.8 KiB
Python

"""
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="<uuid>": 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