247 lines
8.8 KiB
Python
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
|