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

View File

@@ -45,6 +45,21 @@ from routes.email_helpers import (
logger = logging.getLogger(__name__)
def _owner_for_email_account(account_id: str | None) -> str:
if not account_id:
return ""
try:
from core.database import SessionLocal as _SL, EmailAccount as _EA
db = _SL()
try:
row = db.query(_EA.owner).filter(_EA.id == account_id).first()
return (row[0] or "") if row else ""
finally:
db.close()
except Exception:
return ""
# ── Routes ──
async def _emit_progress(progress_cb, message: str):
@@ -143,25 +158,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal:
return "Nothing to do"
# Owner of the account being processed. All calendar reads/writes below are
# scoped to this user: the multi-account fan-out runs every user's mailbox,
# so an unscoped pass would disclose and mutate other tenants' calendars.
_acct_owner = None
try:
from core.database import SessionLocal as _SLo, EmailAccount as _EAo
_dbo = _SLo()
try:
if account_id:
_arow = _dbo.query(_EAo).filter(_EAo.id == account_id).first()
_acct_owner = _arow.owner if _arow else None
finally:
_dbo.close()
except Exception:
_acct_owner = None
# Owner of the account being processed. All calendar + mailbox reads/writes
# below are scoped to this user: the multi-account fan-out runs every user's
# mailbox, so an unscoped pass would disclose/mutate other tenants' data.
# One resolution feeds both the mailbox path (account_owner) and upstream's
# calendar path (_acct_owner, which expects None rather than "").
account_owner = _owner_for_email_account(account_id)
_acct_owner = account_owner or None
try:
await _emit_progress(progress_cb, "Connecting to mail…")
conn = _imap_connect(account_id)
conn = _imap_connect(account_id, owner=account_owner)
from datetime import timedelta as _td
since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y")
# uid_list carries real IMAP UIDs, matching the email UI/read routes.
@@ -212,7 +219,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB)
_sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()}
_reply_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_ai_replies").fetchall()}
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags").fetchall()} if (auto_tag or auto_spam) else set()
if auto_tag or auto_spam:
if account_owner:
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner=?", (account_owner,)).fetchall()}
else:
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner='' OR owner IS NULL").fetchall()}
else:
_tag_existing = set()
_cal_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_calendar_extractions").fetchall()} if auto_cal else set()
# Urgency is handled by the built-in `check_email_urgency` task. Keep
# this legacy poller path disabled so users don't get two independent
@@ -225,7 +238,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
# this per-iteration was making big inbox scans crawl. Used by the
# urgency self-loop check below.
try:
_self_self_addr = (_get_email_config(account_id).get("from_address") or "").strip().lower()
_self_self_addr = (_get_email_config(account_id, owner=account_owner).get("from_address") or "").strip().lower()
except Exception:
_self_self_addr = ""
@@ -233,9 +246,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if auto_spam and not spam_folder:
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
url, model, headers = resolve_endpoint("utility")
url, model, headers = resolve_endpoint("utility", owner=account_owner)
if not url:
url, model, headers = resolve_endpoint("default")
url, model, headers = resolve_endpoint("default", owner=account_owner)
if not url or not model:
conn.logout()
return "No model configured"
@@ -395,8 +408,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
# Background reply drafting should not make the whole app
# feel busy. Keep it lightweight: no extra IMAP context
# mining here; manual AI Reply can still do that when the
# user explicitly asks for a draft on one email.
# mining here; manual AI Reply can still do that (owner-scoped)
# when the user explicitly asks for a draft on one email.
context_snippets, _terms = [], []
sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if att_text:
@@ -711,7 +724,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
# Send alert email immediately if critical or high
if urgency in ("critical", "high"):
try:
cfg = _get_email_config(account_id)
cfg = _get_email_config(account_id, owner=account_owner)
to_addr = cfg["from_address"] # self-email
# Deep-link to open the original email in Odysseus (if public URL is configured).
@@ -846,17 +859,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
moved_to = ""
if is_spam and auto_spam and spam_folder:
if _imap_move(uid, spam_folder):
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
moved_to = spam_folder
logger.info(f"Auto-spam moved uid={uid.decode()} to {spam_folder}: {spam_reason}")
_c = _sql3.connect(SCHEDULED_DB)
_c.execute("""
INSERT OR REPLACE INTO email_tags
(message_id, uid, folder, subject, sender, tags, spam_verdict,
(message_id, owner, uid, folder, subject, sender, tags, spam_verdict,
spam_reason, moved_to, model_used, created_at)
VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, uid.decode(), subject, sender,
VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
""", (message_id, account_owner or "", uid.decode(), subject, sender,
json.dumps(tags), 1 if is_spam else 0,
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
_c.commit()
@@ -936,8 +949,9 @@ def _scheduled_poll_once() -> dict:
conn = sqlite3.connect(SCHEDULED_DB)
cols = [row[1] for row in conn.execute("PRAGMA table_info(scheduled_emails)").fetchall()]
kind_expr = "odysseus_kind" if "odysseus_kind" in cols else "'scheduled' AS odysseus_kind"
owner_expr = "owner" if "owner" in cols else "'' AS owner"
rows = conn.execute(f"""
SELECT id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, attachments, account_id, {kind_expr}
SELECT id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, attachments, account_id, {kind_expr}, {owner_expr}
FROM scheduled_emails
WHERE status = 'pending' AND send_at <= ?
""", (now_iso,)).fetchall()
@@ -949,7 +963,8 @@ def _scheduled_poll_once() -> dict:
attachments = json.loads(r[8] or "[]")
row_account_id = r[9] if len(r) > 9 else None
odysseus_kind = r[10] if len(r) > 10 else "scheduled"
cfg = _get_email_config(row_account_id)
row_owner = (r[11] if len(r) > 11 else "") or _owner_for_email_account(row_account_id)
cfg = _get_email_config(row_account_id, owner=row_owner)
has_atts = bool(attachments)
if has_atts:
outer = MIMEMultipart("mixed")
@@ -986,7 +1001,7 @@ def _scheduled_poll_once() -> dict:
# Append to local Sent folder
try:
with _imap() as imap:
with _imap(row_account_id, owner=row_owner) as imap:
sent_folder = _detect_sent_folder(imap)
imap.append(sent_folder, "\\Seen", None, outer.as_bytes())
except Exception as e:

View File

@@ -90,6 +90,16 @@ def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[st
return out or [""]
def _email_tag_owner_clause(account_id: str | None, owner: str = "") -> tuple[str, list[str]]:
aliases = _email_tag_owner_aliases(account_id, owner)
placeholders = ",".join("?" * len(aliases))
# In configured multi-user mode, do not treat legacy owner='' rows as
# visible to everyone. Single-user/unconfigured mode keeps legacy rows.
if owner:
return f"owner IN ({placeholders})", aliases
return f"(owner IN ({placeholders}) OR owner IS NULL)", aliases
def _record_email_received_events(owner: str, account_id: str | None, folder: str, emails: list[dict]):
"""Baseline inbox messages, then fire `email_received` for new arrivals."""
if not owner or (folder or "INBOX").upper() != "INBOX" or not emails:
@@ -645,8 +655,7 @@ def setup_email_routes():
try:
import sqlite3 as _sql3t
_ct = _sql3t.connect(SCHEDULED_DB)
_owner_aliases = _email_tag_owner_aliases(account_id, owner)
_owner_ph = ",".join("?" * len(_owner_aliases))
_owner_clause, _owner_params = _email_tag_owner_clause(account_id, owner)
# SECURITY: owner-scope the lookup (review C2/H8). Without
# this, user A's `tag:urgent` filter would surface UIDs
# written by user B and IMAP would return whatever
@@ -658,8 +667,8 @@ def setup_email_routes():
rows_t = _ct.execute(
"SELECT message_id, uid FROM email_tags "
"WHERE folder=? AND spam_verdict=1 "
f"AND (owner IN ({_owner_ph}) OR owner IS NULL)",
(folder, *_owner_aliases),
f"AND {_owner_clause}",
(folder, *_owner_params),
).fetchall()
for mid, uid in rows_t:
if mid:
@@ -670,8 +679,8 @@ def setup_email_routes():
rows_t = _ct.execute(
"SELECT message_id, uid, tags FROM email_tags "
"WHERE folder=? AND tags IS NOT NULL AND tags != '' "
f"AND (owner IN ({_owner_ph}) OR owner IS NULL)",
(folder, *_owner_aliases),
f"AND {_owner_clause}",
(folder, *_owner_params),
).fetchall()
for r in rows_t:
try:
@@ -743,12 +752,11 @@ def setup_email_routes():
_uid_strs = [u.decode() for u in uid_list]
if _uid_strs:
placeholders = ",".join("?" * len(_uid_strs))
_owner_aliases = _email_tag_owner_aliases(account_id, owner)
_owner_ph = ",".join("?" * len(_owner_aliases))
_owner_clause, _owner_params = _email_tag_owner_clause(account_id, owner)
rows = _c.execute(
f"SELECT uid, tags, spam_verdict FROM email_tags "
f"WHERE folder=? AND (owner IN ({_owner_ph}) OR owner IS NULL) AND uid IN ({placeholders})",
[folder, *_owner_aliases, *_uid_strs],
f"WHERE folder=? AND {_owner_clause} AND uid IN ({placeholders})",
[folder, *_owner_params, *_uid_strs],
).fetchall()
for r in rows:
try:
@@ -805,14 +813,13 @@ def setup_email_routes():
if header_ids:
import sqlite3 as _sql3m
_cm = _sql3m.connect(SCHEDULED_DB)
_owner_aliases_m = _email_tag_owner_aliases(account_id, owner)
_owner_ph_m = ",".join("?" * len(_owner_aliases_m))
_owner_clause_m, _owner_params_m = _email_tag_owner_clause(account_id, owner)
_mid_ph = ",".join("?" * len(header_ids))
rows_m = _cm.execute(
f"SELECT message_id, tags, spam_verdict FROM email_tags "
f"WHERE folder=? AND (owner IN ({_owner_ph_m}) OR owner IS NULL) "
f"WHERE folder=? AND {_owner_clause_m} "
f"AND message_id IN ({_mid_ph})",
[folder, *_owner_aliases_m, *header_ids],
[folder, *_owner_params_m, *header_ids],
).fetchall()
_cm.close()
for mid, tags_raw, spam_raw in rows_m:
@@ -971,10 +978,11 @@ def setup_email_routes():
async def unflag_spam(uid: str, owner: str = Depends(require_owner)):
"""User override — mark email as not spam."""
try:
owner_clause, owner_params = _email_tag_owner_clause(None, owner)
_c = _sql3.connect(SCHEDULED_DB)
_c.execute(
"UPDATE email_tags SET spam_verdict=0, spam_reason='' WHERE uid=?",
(uid,),
f"UPDATE email_tags SET spam_verdict=0, spam_reason='' WHERE uid=? AND {owner_clause}",
[uid, *owner_params],
)
_c.commit()
_c.close()
@@ -997,8 +1005,10 @@ def setup_email_routes():
ql = (q or "").strip().lower()
try:
conn = _sql3.connect(SCHEDULED_DB)
owner_clause, owner_params = _email_tag_owner_clause(None, owner)
rows = conn.execute(
"SELECT sender FROM email_tags WHERE sender IS NOT NULL AND sender != ''"
f"SELECT sender FROM email_tags WHERE sender IS NOT NULL AND sender != '' AND {owner_clause}",
owner_params,
).fetchall()
conn.close()
seen = {}
@@ -1969,8 +1979,8 @@ def setup_email_routes():
conn = sqlite3.connect(SCHEDULED_DB)
conn.execute("""
INSERT INTO scheduled_emails
(id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, attachments, send_at, created_at, status, account_id, odysseus_kind)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
(id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, attachments, send_at, created_at, status, account_id, odysseus_kind, owner)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
""", (
sid,
req.get("to", ""),
@@ -1985,6 +1995,7 @@ def setup_email_routes():
datetime.utcnow().isoformat(),
req.get("account_id") or None,
req.get("odysseus_kind") or "scheduled",
owner or "",
))
conn.commit()
conn.close()
@@ -2003,9 +2014,9 @@ def setup_email_routes():
rows = conn.execute("""
SELECT id, to_addr, cc, subject, send_at, created_at, status, error
FROM scheduled_emails
WHERE status IN ('pending', 'failed')
WHERE status IN ('pending', 'failed') AND owner = ?
ORDER BY send_at ASC
""").fetchall()
""", (owner or "",)).fetchall()
conn.close()
return {"scheduled": [
{
@@ -2023,7 +2034,10 @@ def setup_email_routes():
import sqlite3
try:
conn = sqlite3.connect(SCHEDULED_DB)
conn.execute("DELETE FROM scheduled_emails WHERE id = ? AND status = 'pending'", (sid,))
conn.execute(
"DELETE FROM scheduled_emails WHERE id = ? AND status = 'pending' AND owner = ?",
(sid, owner or ""),
)
conn.commit()
conn.close()
return {"success": True}
@@ -2035,7 +2049,7 @@ def setup_email_routes():
async def resolve_contact(name: str = Query(..., description="Name to search for"), owner: str = Depends(require_owner)):
"""Search Sent folder for a contact by name. Returns matching email addresses."""
try:
with _imap() as conn:
with _imap(owner=owner) as conn:
matches = {}
for folder in ["Sent", "INBOX", "Drafts"]:
try:
@@ -2590,7 +2604,7 @@ def setup_email_routes():
# `api_key` field.
from core.database import SessionLocal as _SL, Session as _CS
_db = _SL()
sess = _db.query(_CS).filter(_CS.id == session_id).first()
sess = _db.query(_CS).filter(_CS.id == session_id, _CS.owner == owner).first()
if sess and sess.endpoint_url:
url = sess.endpoint_url
# Some sessions stored headers double-encoded (a JSON
@@ -2649,9 +2663,10 @@ def setup_email_routes():
# Manual AI Reply should feel immediate. The heavier context mining
# can involve multiple IMAP folder searches and attachment parsing;
# reserve that for callers that explicitly opt out of fast mode.
# Owner-scoped so pre-retrieval never crosses tenants.
context_snippets, _terms = ([], [])
if not fast_reply:
context_snippets, _terms = _pre_retrieve_context(original_body, to)
context_snippets, _terms = _pre_retrieve_context(original_body, to, owner=owner)
# NEW: also pull the last few emails from the original sender +
# their attachments. The "to" field on this endpoint is the
@@ -2667,6 +2682,7 @@ def setup_email_routes():
exclude_uid=source_uid,
exclude_folder=source_folder,
limit=3,
owner=owner,
)
except Exception as _e:
logger.warning(f"sender-thread-context failed: {_e}")
@@ -2728,7 +2744,7 @@ def setup_email_routes():
# Configured fallback chains last.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates() or []:
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
try:
reply = await llm_call_async_with_fallback(
@@ -2819,9 +2835,12 @@ def setup_email_routes():
import uuid as _uuid
db = SessionLocal()
try:
row = db.query(EmailAccount).filter(EmailAccount.is_default == True).first() # noqa: E712
q = db.query(EmailAccount).filter(EmailAccount.is_default == True) # noqa: E712
if owner:
q = q.filter(EmailAccount.owner == owner)
row = q.first()
if row is None:
row = EmailAccount(id=_uuid.uuid4().hex, name="Default", is_default=True, enabled=True)
row = EmailAccount(id=_uuid.uuid4().hex, owner=owner, name="Default", is_default=True, enabled=True)
db.add(row)
field_map = {
"smtp_host": "smtp_host", "smtp_port": "smtp_port", "smtp_user": "smtp_user",
@@ -2843,6 +2862,10 @@ def setup_email_routes():
row.imap_password = _enc(data["imap_password"])
if data.get("smtp_password"):
row.smtp_password = _enc(data["smtp_password"])
clear_q = db.query(EmailAccount).filter(EmailAccount.id != row.id)
if owner:
clear_q = clear_q.filter(EmailAccount.owner == owner)
clear_q.update({EmailAccount.is_default: False})
db.commit()
finally:
db.close()

View File

@@ -0,0 +1,154 @@
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
def _route_endpoint(router, path: str, method: str):
method = method.upper()
for route in router.routes:
if route.path == path and method in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"route not found: {method} {path}")
def test_email_tag_clause_excludes_legacy_owner_rows_for_authenticated_owner(monkeypatch):
import routes.email_routes as email_routes
monkeypatch.setattr(
email_routes,
"_email_tag_owner_aliases",
lambda account_id, owner="": ["alice", "alice@example.com"],
)
clause, params = email_routes._email_tag_owner_clause("acct-alice", "alice")
assert clause == "owner IN (?,?)"
assert params == ["alice", "alice@example.com"]
assert "owner IS NULL" not in clause
def test_email_tag_clause_keeps_legacy_rows_for_single_user_mode(monkeypatch):
import routes.email_routes as email_routes
monkeypatch.setattr(
email_routes,
"_email_tag_owner_aliases",
lambda account_id, owner="": [""],
)
clause, params = email_routes._email_tag_owner_clause(None, "")
assert clause == "(owner IN (?) OR owner IS NULL)"
assert params == [""]
@pytest.mark.asyncio
async def test_scheduled_email_routes_are_owner_scoped(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.email_routes as email_routes
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
router = email_routes.setup_email_routes()
schedule_email = _route_endpoint(router, "/api/email/schedule", "POST")
list_scheduled = _route_endpoint(router, "/api/email/scheduled", "GET")
cancel_scheduled = _route_endpoint(router, "/api/email/scheduled/{sid}", "DELETE")
send_at = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
alice = await schedule_email(
{"to": "a@example.com", "body": "alice body", "send_at": send_at},
owner="alice",
)
bob = await schedule_email(
{"to": "b@example.com", "body": "bob body", "send_at": send_at},
owner="bob",
)
assert alice["success"] is True
assert bob["success"] is True
alice_rows = await list_scheduled(owner="alice")
bob_rows = await list_scheduled(owner="bob")
assert [row["id"] for row in alice_rows["scheduled"]] == [alice["id"]]
assert [row["id"] for row in bob_rows["scheduled"]] == [bob["id"]]
await cancel_scheduled(bob["id"], owner="alice")
bob_rows = await list_scheduled(owner="bob")
assert [row["id"] for row in bob_rows["scheduled"]] == [bob["id"]]
await cancel_scheduled(alice["id"], owner="alice")
alice_rows = await list_scheduled(owner="alice")
assert alice_rows["scheduled"] == []
def test_scheduled_poller_resolves_config_with_row_owner(tmp_path, monkeypatch):
import routes.email_helpers as email_helpers
import routes.email_pollers as email_pollers
db_path = tmp_path / "scheduled_emails.db"
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
email_helpers._init_scheduled_db()
conn = sqlite3.connect(db_path)
conn.execute(
"""
INSERT INTO scheduled_emails
(id, to_addr, subject, body, attachments, send_at, created_at, status, account_id, owner)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
""",
(
"sched-1",
"recipient@example.com",
"Subject",
"Body",
"[]",
"2000-01-01T00:00:00",
"1999-12-31T00:00:00",
"acct-alice",
"alice",
),
)
conn.commit()
conn.close()
calls = []
def fake_get_email_config(account_id=None, owner=""):
calls.append(("config", account_id, owner))
return {
"from_address": "alice@example.com",
"smtp_host": "smtp.example.com",
"smtp_user": "alice@example.com",
"smtp_password": "secret",
}
class FakeImap:
def __init__(self, account_id=None, owner=""):
calls.append(("imap", account_id, owner))
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def append(self, folder, flags, date_time, message):
calls.append(("append", folder))
monkeypatch.setattr(email_pollers, "_get_email_config", fake_get_email_config)
monkeypatch.setattr(email_pollers, "_send_smtp_message", lambda *args, **kwargs: calls.append(("send", args[1], args[2])))
monkeypatch.setattr(email_pollers, "_imap", FakeImap)
monkeypatch.setattr(email_pollers, "_detect_sent_folder", lambda imap: "Sent")
monkeypatch.setattr(email_pollers, "_cleanup_compose_uploads", lambda attachments: calls.append(("cleanup", attachments)))
result = email_pollers._scheduled_poll_once()
assert result == {"sent": ["sched-1"], "failed": []}
assert ("config", "acct-alice", "alice") in calls
assert ("imap", "acct-alice", "alice") in calls