Scope email account workflows by owner (#1309)

This commit is contained in:
Vykos
2026-06-02 19:21:02 +02:00
committed by GitHub
parent e73545f64f
commit 1adf21a7e5
4 changed files with 322 additions and 77 deletions

View File

@@ -297,7 +297,8 @@ def _init_scheduled_db():
send_at TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT
error TEXT,
owner TEXT DEFAULT ''
)
""")
# Email summary cache (keyed by Message-ID)
@@ -435,6 +436,35 @@ def _init_scheduled_db():
conn.execute("ALTER TABLE scheduled_emails ADD COLUMN account_id TEXT")
if "odysseus_kind" not in cols:
conn.execute("ALTER TABLE scheduled_emails ADD COLUMN odysseus_kind TEXT")
if "owner" not in cols:
conn.execute("ALTER TABLE scheduled_emails ADD COLUMN owner TEXT DEFAULT ''")
conn.execute("CREATE INDEX IF NOT EXISTS ix_scheduled_emails_owner_status ON scheduled_emails(owner, status)")
# Backfill owner on legacy rows from the owning email account so the
# owner-scoped list/cancel routes surface pre-migration scheduled
# sends to the right user (the poller already resolves these by
# account at send time; this aligns the UI with that).
legacy_accounts = conn.execute(
"SELECT DISTINCT account_id FROM scheduled_emails "
"WHERE (owner IS NULL OR owner = '') AND account_id IS NOT NULL AND account_id != ''"
).fetchall()
if legacy_accounts:
try:
from core.database import SessionLocal as _SL, EmailAccount as _EA
_db = _SL()
try:
for (acct_id,) in legacy_accounts:
row = _db.query(_EA.owner).filter(_EA.id == acct_id).first()
acct_owner = (row[0] or "") if row else ""
if acct_owner:
conn.execute(
"UPDATE scheduled_emails SET owner = ? "
"WHERE account_id = ? AND (owner IS NULL OR owner = '')",
(acct_owner, acct_id),
)
finally:
_db.close()
except Exception:
pass
except Exception:
pass
# Lazy migration: add turns_json to email_boundaries for server-side
@@ -815,10 +845,10 @@ def _detect_spam_folder(conn):
return None
def _imap_move(uid, dest, src="INBOX"):
def _imap_move(uid, dest, src="INBOX", account_id: str | None = None, owner: str = ""):
"""Move a single IMAP UID from src folder to dest. Returns True on success."""
try:
c = _imap_connect()
c = _imap_connect(account_id, owner=owner)
c.select(_q(src))
status, _ = c.copy(uid, _q(dest))
if status != "OK":
@@ -1021,7 +1051,9 @@ def _fetch_sender_thread_context(sender_addr: str,
exclude_folder: str = "INBOX",
limit: int = 3,
max_chars_per_email: int = 1500,
max_attachment_chars: int = 4000) -> str:
max_attachment_chars: int = 4000,
account_id: str | None = None,
owner: str = "") -> str:
"""Pull the last N emails from `sender_addr` (across common folders),
extract their body snippets + attachment text, and return one formatted
block ready to be glued into an LLM system prompt as "REFERENCED MATERIAL".
@@ -1043,7 +1075,7 @@ def _fetch_sender_thread_context(sender_addr: str,
seen_uids.add((exclude_folder or "INBOX", str(exclude_uid)))
try:
conn = _imap_connect()
conn = _imap_connect(account_id, owner=owner)
except Exception as e:
logger.warning(f"sender-thread-context: imap connect failed: {e}")
return ""
@@ -1126,7 +1158,12 @@ def _fetch_sender_thread_context(sender_addr: str,
return "\n\n=====\n\n".join(blocks)
def _pre_retrieve_context(body: str, sender: str) -> tuple:
def _pre_retrieve_context(
body: str,
sender: str,
account_id: str | None = None,
owner: str = "",
) -> tuple:
"""Extract key terms from an incoming email and search past emails + contacts.
Returns (context_snippets, terms_list). Best-effort; never raises.
@@ -1150,21 +1187,37 @@ def _pre_retrieve_context(body: str, sender: str) -> tuple:
# ── Known-sender check: only retrieve context for senders we already
# have a relationship with. New / cold senders get an empty context.
sender_addr = email.utils.parseaddr(sender or "")[1].lower()
is_known = False
# The CardDAV address book is global admin data backed by a single
# Radicale instance, so only fold it into reply context for an admin /
# single-user owner. Non-admin owners still get their own (owner-scoped)
# IMAP history below, just not the shared contacts.
try:
from routes.contacts_routes import _fetch_contacts
for c in _fetch_contacts() or []:
# Contacts are normalized to plural `emails` lists (see
# contacts_routes._normalize_contact); the old `c.get("email")`
# singular key never exists, so known senders were never matched.
if sender_addr in [(e or "").lower() for e in (c.get("emails") or [])]:
is_known = True
break
from src.tool_security import owner_is_admin_or_single_user
contacts_allowed = owner_is_admin_or_single_user(owner or None)
except Exception:
pass
contacts_allowed = not bool(owner)
is_known = False
if contacts_allowed:
try:
from routes.contacts_routes import _fetch_contacts
for c in _fetch_contacts() or []:
# Contacts are normalized to plural `emails` lists, but
# keep the legacy singular key fallback for older data.
contact_emails = []
raw_emails = c.get("emails")
if isinstance(raw_emails, list):
contact_emails.extend(str(e or "") for e in raw_emails)
legacy_email = c.get("email")
if legacy_email:
contact_emails.append(str(legacy_email))
if any((addr or "").strip().lower() == sender_addr for addr in contact_emails):
is_known = True
break
except Exception:
pass
if not is_known and sender_addr:
try:
with _imap() as _ck:
with _imap(account_id, owner=owner) as _ck:
_ck.select("INBOX", readonly=True)
st_known, dk = _ck.search(None, f'(FROM "{sender_addr}")')
if st_known == "OK" and dk and dk[0]:
@@ -1202,7 +1255,7 @@ def _pre_retrieve_context(body: str, sender: str) -> tuple:
return context_snippets, terms_list
try:
ctx_conn = _imap_connect()
ctx_conn = _imap_connect(account_id, owner=owner)
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
try:
st_sel, _sd = ctx_conn.select(_q(folder), readonly=True)
@@ -1246,7 +1299,7 @@ def _pre_retrieve_context(body: str, sender: str) -> tuple:
try:
from routes.contacts_routes import _fetch_contacts
all_contacts = _fetch_contacts()
all_contacts = _fetch_contacts() if contacts_allowed else []
for term in terms_list:
t_lower = term.lower()
matches = [c for c in all_contacts