Scope email account workflows by owner (#1309)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user