diff --git a/routes/email_helpers.py b/routes/email_helpers.py index c8639e1c7..494e8d2d5 100644 --- a/routes/email_helpers.py +++ b/routes/email_helpers.py @@ -29,10 +29,11 @@ from email.mime.base import MIMEBase from email import encoders import mimetypes from pathlib import Path +from datetime import datetime from fastapi import Query, HTTPException, Request from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, Any from src.auth_helpers import _auth_disabled, get_current_user from src.secret_storage import decrypt as _decrypt @@ -443,6 +444,46 @@ COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True) SCHEDULED_DB = Path(SCHEDULED_EMAILS_DB) +DEFAULT_EMAIL_URGENCY_LEVELS = [ + { + "slug": "urgent", + "name": "Urgent", + "description": "Needs action now or blocks someone else.", + "examples": ["Someone is waiting", "Hard deadline today", "Account or access blocker"], + "color": "red", + "rank": 300, + "notify": True, + }, + { + "slug": "reply-soon", + "name": "Reply soon", + "description": "Needs a response soon, usually within a day.", + "examples": ["Scheduling request", "Question that needs a timely answer"], + "color": "orange", + "rank": 200, + "notify": True, + }, + { + "slug": "info", + "name": "Info", + "description": "Useful information with no immediate response required.", + "examples": ["Status update", "Reference material"], + "color": "blue", + "rank": 100, + "notify": False, + }, + { + "slug": "ignore", + "name": "Ignore", + "description": "No user action is needed.", + "examples": ["Promotional email", "No personal action required"], + "color": "muted", + "rank": 0, + "notify": False, + }, +] + + OWNER_SCOPED_EMAIL_CACHE_TABLES = { "email_summaries", "email_ai_replies", @@ -576,6 +617,403 @@ def attachment_extract_dir(folder: str, uid: str) -> Path: return target +def normalize_email_urgency_slug(value: str) -> str: + """Normalize user-facing urgency names into stable local identifiers.""" + slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").strip().lower()).strip("-") + return slug[:48] + + +def _coerce_email_urgency_examples(value: Any) -> list[str]: + if isinstance(value, str): + raw = [line.strip() for line in value.replace(";", "\n").splitlines()] + elif isinstance(value, list): + raw = [str(v or "").strip() for v in value] + else: + raw = [] + out: list[str] = [] + for item in raw: + if item and item not in out: + out.append(item[:180]) + if len(out) >= 8: + break + return out + + +def _coerce_email_urgency_color(value: str) -> str: + color = str(value or "").strip().lower() + if re.fullmatch(r"#[0-9a-f]{3}(?:[0-9a-f]{3})?", color): + return color + if re.fullmatch(r"[a-z][a-z0-9_-]{0,24}", color): + return color + return "muted" + + +def _email_urgency_row_to_dict(row) -> dict: + try: + examples = json.loads(row["examples"] or "[]") + except Exception: + examples = [] + if not isinstance(examples, list): + examples = [] + return { + "slug": row["slug"], + "name": row["name"], + "description": row["description"] or "", + "examples": [str(x) for x in examples if str(x).strip()], + "color": row["color"] or "muted", + "rank": int(row["rank"] or 0), + "notify": bool(row["notify"]), + "active": bool(row["active"]), + "created_at": row["created_at"] or "", + "updated_at": row["updated_at"] or "", + } + + +def ensure_email_urgency_defaults(owner: str = "") -> None: + """Seed the default urgency levels for a user if they do not exist.""" + import sqlite3 + owner = (owner or "").strip() + now = datetime.utcnow().isoformat() + conn = sqlite3.connect(SCHEDULED_DB) + try: + conn.row_factory = sqlite3.Row + for level in DEFAULT_EMAIL_URGENCY_LEVELS: + conn.execute( + """ + INSERT OR IGNORE INTO email_urgency_levels + (owner, slug, name, description, examples, color, rank, notify, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + """, + ( + owner, + level["slug"], + level["name"], + level["description"], + json.dumps(level.get("examples") or []), + level["color"], + int(level["rank"]), + 1 if level.get("notify") else 0, + now, + now, + ), + ) + conn.commit() + finally: + conn.close() + + +def list_email_urgency_levels(owner: str = "", include_inactive: bool = False) -> list[dict]: + import sqlite3 + ensure_email_urgency_defaults(owner) + conn = sqlite3.connect(SCHEDULED_DB) + conn.row_factory = sqlite3.Row + try: + where = "owner=?" + params: list[Any] = [(owner or "").strip()] + if not include_inactive: + where += " AND active=1" + rows = conn.execute( + f"SELECT * FROM email_urgency_levels WHERE {where} ORDER BY rank DESC, name COLLATE NOCASE ASC", + params, + ).fetchall() + return [_email_urgency_row_to_dict(row) for row in rows] + finally: + conn.close() + + +def save_email_urgency_level(owner: str = "", data: dict | None = None, existing_slug: str | None = None) -> dict: + import sqlite3 + owner = (owner or "").strip() + data = data or {} + ensure_email_urgency_defaults(owner) + current_slug = normalize_email_urgency_slug(existing_slug or "") + raw_name = str(data.get("name") or "").strip() + raw_slug = str(data.get("slug") or "").strip() + slug = normalize_email_urgency_slug(raw_slug or raw_name or current_slug) + if not slug: + raise HTTPException(400, "Urgency level name is required") + name = raw_name or slug.replace("-", " ").title() + description = str(data.get("description") or "").strip()[:600] + examples = _coerce_email_urgency_examples(data.get("examples")) + color = _coerce_email_urgency_color(data.get("color") or "") + try: + rank = int(data.get("rank", 0)) + except Exception: + raise HTTPException(400, "Urgency rank must be a whole number") + rank = max(-1000, min(1000, rank)) + notify = 1 if bool(data.get("notify")) else 0 + active = 0 if data.get("active") is False else 1 + now = datetime.utcnow().isoformat() + conn = sqlite3.connect(SCHEDULED_DB) + conn.row_factory = sqlite3.Row + try: + if current_slug and current_slug != slug: + exists = conn.execute( + "SELECT 1 FROM email_urgency_levels WHERE owner=? AND slug=?", + (owner, slug), + ).fetchone() + if exists: + raise HTTPException(409, "An urgency level with that name already exists") + conn.execute( + "UPDATE email_urgency_assignments SET urgency_slug=?, updated_at=? WHERE owner=? AND urgency_slug=?", + (slug, now, owner, current_slug), + ) + cur = conn.execute( + """ + UPDATE email_urgency_levels + SET slug=?, name=?, description=?, examples=?, color=?, rank=?, notify=?, active=?, updated_at=? + WHERE owner=? AND slug=? + """, + ( + slug, name, description, json.dumps(examples), color, rank, notify, active, now, + owner, current_slug, + ), + ) + if cur.rowcount == 0: + raise HTTPException(404, "Urgency level not found") + else: + conn.execute( + """ + INSERT INTO email_urgency_levels + (owner, slug, name, description, examples, color, rank, notify, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(owner, slug) DO UPDATE SET + name=excluded.name, + description=excluded.description, + examples=excluded.examples, + color=excluded.color, + rank=excluded.rank, + notify=excluded.notify, + active=excluded.active, + updated_at=excluded.updated_at + """, + (owner, slug, name, description, json.dumps(examples), color, rank, notify, active, now, now), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM email_urgency_levels WHERE owner=? AND slug=?", + (owner, slug), + ).fetchone() + return _email_urgency_row_to_dict(row) + finally: + conn.close() + + +def archive_email_urgency_level(owner: str = "", slug: str = "") -> dict: + import sqlite3 + owner = (owner or "").strip() + slug = normalize_email_urgency_slug(slug) + if not slug: + raise HTTPException(400, "Urgency level is required") + ensure_email_urgency_defaults(owner) + now = datetime.utcnow().isoformat() + conn = sqlite3.connect(SCHEDULED_DB) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT * FROM email_urgency_levels WHERE owner=? AND slug=?", + (owner, slug), + ).fetchone() + if not row: + raise HTTPException(404, "Urgency level not found") + conn.execute( + "UPDATE email_urgency_levels SET active=0, updated_at=? WHERE owner=? AND slug=?", + (now, owner, slug), + ) + conn.execute( + "DELETE FROM email_urgency_assignments WHERE owner=? AND urgency_slug=?", + (owner, slug), + ) + conn.commit() + return {"success": True, "slug": slug} + finally: + conn.close() + + +def email_urgency_message_key(message_id: str = "", folder: str = "", uid: str = "") -> str: + message_id = str(message_id or "").strip() + if message_id: + return f"mid:{message_id}" + folder_key = str(folder or "INBOX").strip() or "INBOX" + uid_key = str(uid or "").strip() + if uid_key: + return f"uid:{folder_key}:{uid_key}" + return "" + + +def email_urgency_slug_for_score(owner: str = "", score: int = 0) -> str: + """Map the existing 0-3 urgency scanner score into the configured levels.""" + try: + score_i = int(score or 0) + except Exception: + score_i = 0 + levels = {level["slug"]: level for level in list_email_urgency_levels(owner)} + if score_i >= 3 and "urgent" in levels: + return "urgent" + if score_i >= 2 and "reply-soon" in levels: + return "reply-soon" + if score_i >= 1 and "info" in levels: + return "info" + if "ignore" in levels: + return "ignore" + active = sorted(levels.values(), key=lambda level: int(level.get("rank") or 0), reverse=True) + if not active: + return "" + if score_i <= 0: + return active[-1]["slug"] + if score_i >= 3: + return active[0]["slug"] + idx = min(len(active) - 1, max(0, 3 - score_i)) + return active[idx]["slug"] + + +def upsert_email_urgency_assignment( + *, + owner: str = "", + account_id: str | None = None, + message_id: str = "", + uid: str = "", + folder: str = "INBOX", + urgency_slug: str = "", + reason: str = "", + confidence: float | None = None, + source: str = "manual", + subject: str = "", + sender: str = "", +) -> dict: + import sqlite3 + owner = (owner or "").strip() + account_id = (account_id or "").strip() + folder = (folder or "INBOX").strip() or "INBOX" + urgency_slug = normalize_email_urgency_slug(urgency_slug) + message_key = email_urgency_message_key(message_id, folder, uid) + if not message_key: + raise HTTPException(400, "Message identity is required") + ensure_email_urgency_defaults(owner) + levels = {level["slug"]: level for level in list_email_urgency_levels(owner)} + if urgency_slug not in levels: + raise HTTPException(404, "Urgency level not found") + try: + confidence_value = float(confidence) if confidence is not None else 1.0 + except Exception: + confidence_value = 1.0 + confidence_value = max(0.0, min(1.0, confidence_value)) + now = datetime.utcnow().isoformat() + conn = sqlite3.connect(SCHEDULED_DB) + try: + conn.execute( + """ + INSERT INTO email_urgency_assignments + (owner, account_id, message_key, message_id, uid, folder, urgency_slug, confidence, + reason, source, subject, sender, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(owner, account_id, message_key) DO UPDATE SET + message_id=excluded.message_id, + uid=excluded.uid, + folder=excluded.folder, + urgency_slug=excluded.urgency_slug, + confidence=excluded.confidence, + reason=excluded.reason, + source=excluded.source, + subject=excluded.subject, + sender=excluded.sender, + updated_at=excluded.updated_at + """, + ( + owner, account_id, message_key, str(message_id or "").strip(), str(uid or "").strip(), folder, + urgency_slug, confidence_value, str(reason or "")[:500], str(source or "manual")[:40], + str(subject or "")[:300], str(sender or "")[:300], now, now, + ), + ) + conn.commit() + finally: + conn.close() + level = dict(levels[urgency_slug]) + return { + **level, + "reason": str(reason or "")[:500], + "confidence": confidence_value, + "source": str(source or "manual")[:40], + "message_key": message_key, + } + + +def clear_email_urgency_assignment( + *, + owner: str = "", + account_id: str | None = None, + message_id: str = "", + uid: str = "", + folder: str = "INBOX", +) -> dict: + import sqlite3 + owner = (owner or "").strip() + account_id = (account_id or "").strip() + message_key = email_urgency_message_key(message_id, folder, uid) + if not message_key: + raise HTTPException(400, "Message identity is required") + conn = sqlite3.connect(SCHEDULED_DB) + try: + conn.execute( + "DELETE FROM email_urgency_assignments WHERE owner=? AND account_id=? AND message_key=?", + (owner, account_id, message_key), + ) + conn.commit() + finally: + conn.close() + return {"success": True, "cleared": True, "message_key": message_key} + + +def load_email_urgency_assignments( + *, + owner: str = "", + account_id: str | None = None, + folder: str = "INBOX", + emails: list[dict] | None = None, +) -> dict[str, dict]: + import sqlite3 + emails = emails or [] + owner = (owner or "").strip() + account_id = (account_id or "").strip() + keys = [] + for e in emails: + key = email_urgency_message_key(e.get("message_id") or "", e.get("folder") or folder, e.get("uid") or "") + if key and key not in keys: + keys.append(key) + if not keys: + return {} + ensure_email_urgency_defaults(owner) + conn = sqlite3.connect(SCHEDULED_DB) + conn.row_factory = sqlite3.Row + try: + placeholders = ",".join("?" * len(keys)) + rows = conn.execute( + f""" + SELECT a.message_key, a.reason, a.confidence, a.source, a.updated_at AS assigned_at, + l.slug, l.name, l.description, l.examples, l.color, l.rank, l.notify, l.active, + l.created_at, l.updated_at + FROM email_urgency_assignments a + JOIN email_urgency_levels l + ON l.owner=a.owner AND l.slug=a.urgency_slug + WHERE a.owner=? AND a.account_id=? AND a.message_key IN ({placeholders}) AND l.active=1 + """, + (owner, account_id, *keys), + ).fetchall() + out = {} + for row in rows: + level = _email_urgency_row_to_dict(row) + level.update({ + "reason": row["reason"] or "", + "confidence": float(row["confidence"] or 0), + "source": row["source"] or "", + "assigned_at": row["assigned_at"] or "", + }) + out[row["message_key"]] = level + return out + finally: + conn.close() + + def _init_scheduled_db(): import sqlite3 conn = sqlite3.connect(SCHEDULED_DB) @@ -738,6 +1176,53 @@ def _init_scheduled_db(): PRIMARY KEY (message_id, owner) ) """, ["message_id", "owner", "uid", "folder", "subject", "sender", "urgency", "reason", "alerted", "created_at"]) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_urgency_levels ( + owner TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + examples TEXT DEFAULT '[]', + color TEXT DEFAULT 'muted', + rank INTEGER DEFAULT 0, + notify INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (owner, slug) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_urgency_levels_owner_active_rank + ON email_urgency_levels(owner, active, rank) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_urgency_assignments ( + owner TEXT NOT NULL DEFAULT '', + account_id TEXT NOT NULL DEFAULT '', + message_key TEXT NOT NULL, + message_id TEXT DEFAULT '', + uid TEXT DEFAULT '', + folder TEXT NOT NULL DEFAULT 'INBOX', + urgency_slug TEXT NOT NULL, + confidence REAL DEFAULT 1.0, + reason TEXT DEFAULT '', + source TEXT DEFAULT 'manual', + subject TEXT DEFAULT '', + sender TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (owner, account_id, message_key) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_urgency_assignments_filter + ON email_urgency_assignments(owner, account_id, folder, urgency_slug) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_urgency_assignments_message + ON email_urgency_assignments(owner, account_id, message_id) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS email_event_seen ( owner TEXT NOT NULL, diff --git a/routes/email_routes.py b/routes/email_routes.py index 3c8e407bd..439a6c030 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -61,6 +61,9 @@ from routes.email_helpers import ( SendEmailRequest, ExtractStyleRequest, ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB, attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash, + normalize_email_urgency_slug, list_email_urgency_levels, save_email_urgency_level, + archive_email_urgency_level, upsert_email_urgency_assignment, clear_email_urgency_assignment, + load_email_urgency_assignments, email_urgency_message_key, ) from routes.email_pollers import _start_poller @@ -248,6 +251,17 @@ def _email_tag_account_clause(account_id: str | None) -> tuple[str, list[str]]: return "1=1", [] +def _effective_email_account_id(account_id: str | None, owner: str = "") -> str: + account = (account_id or "").strip() + if account: + return account + try: + cfg = _get_email_config(None, owner=owner) + return (cfg.get("account_id") or "").strip() + except Exception: + return "" + + _VISIBLE_EMAIL_TAGS = {"urgent", "reply-soon", "action-needed", "calendar", "bills", "receipt", "travel"} _DONE_RESPONSE_TAGS = {"urgent", "reply-soon", "action-needed"} @@ -303,6 +317,31 @@ def _clear_done_response_tags(owner: str, account_id: str | None, folder: str, u logger.debug(f"clear done response tags skipped: {e}") +def _attach_email_urgency(owner: str, account_id: str | None, folder: str, emails: list[dict]) -> None: + if not emails: + return + try: + assignments = load_email_urgency_assignments( + owner=owner, + account_id=_effective_email_account_id(account_id, owner), + folder=folder, + emails=emails, + ) + for e in emails: + key = email_urgency_message_key( + e.get("message_id") or "", + e.get("folder") or folder, + e.get("uid") or "", + ) + urgency = assignments.get(key) + if urgency: + e["urgency"] = urgency + else: + e.pop("urgency", None) + except Exception as e: + logger.debug(f"email urgency attach skipped: {e}") + + 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: @@ -1838,6 +1877,50 @@ def setup_email_routes(): from datetime import datetime as _dt, timedelta as _td _before = (_dt.utcnow() - _td(days=30)).strftime("%d-%b-%Y") status, data = _imap_uid_search(conn, f'(UNANSWERED BEFORE "{_before}"{from_clause})') + elif filter_ and filter_.startswith("urgency:"): + _urgency_slug = normalize_email_urgency_slug(filter_[len("urgency:"):]) + _urgency_message_ids = [] + _urgency_uid_fallback = [] + try: + import sqlite3 as _sql3u + _cu = _sql3u.connect(SCHEDULED_DB) + _resolved_account = _effective_email_account_id(account_id, owner) + rows_u = _cu.execute( + """ + SELECT message_id, uid + FROM email_urgency_assignments + WHERE owner=? AND account_id=? AND folder=? AND urgency_slug=? + """, + (owner or "", _resolved_account, folder, _urgency_slug), + ).fetchall() + _cu.close() + for mid, uid in rows_u: + if mid: + _urgency_message_ids.append(str(mid).strip()) + elif uid: + _urgency_uid_fallback.append(str(uid).strip()) + except Exception as _ue: + logger.warning(f"urgency filter lookup failed: {_ue}") + if not _urgency_message_ids and not _urgency_uid_fallback: + return {"emails": [], "total": 0, "folder": folder} + + def _imap_search_quote(value: str) -> str: + return '"' + str(value or "").replace("\\", "\\\\").replace('"', '\\"') + '"' + + _uids = set() + for _mid in dict.fromkeys(_urgency_message_ids): + if not _mid: + continue + st_m, data_m = _imap_uid_search(conn, f'(HEADER Message-ID {_imap_search_quote(_mid)}{from_clause})') + if st_m == "OK" and data_m and data_m[0]: + _uids.update(data_m[0].split()) + for _uid in _urgency_uid_fallback: + if _uid: + _uids.add(str(_uid).encode()) + if not _uids: + return {"emails": [], "total": 0, "folder": folder} + data = [b" ".join(sorted(_uids, key=lambda x: int(x) if str(x, "ascii", "ignore").isdigit() else 0))] + status = "OK" elif filter_ and filter_.startswith("tag:"): # Tag-based filter — resolve UIDs from email_tags first, then # ask IMAP for those messages by Message-ID. `tag:spam` reads @@ -2143,11 +2226,19 @@ def setup_email_routes(): except Exception as e: logger.debug(f"email calendar event link attach skipped: {e}") + _attach_email_urgency(owner, account_id, folder, emails) _hide_unlinked_calendar_tags(emails) if filter_ and filter_.startswith("tag:") and filter_ != "tag:spam": _final_tag = filter_[len("tag:"):].strip().lower().replace("_", "-") emails = [e for e in emails if _final_tag in (e.get("tags") or [])] total = len(emails) + if filter_ and filter_.startswith("urgency:"): + _final_urgency = normalize_email_urgency_slug(filter_[len("urgency:"):]) + emails = [ + e for e in emails + if (e.get("urgency") or {}).get("slug") == _final_urgency + ] + total = len(emails) if has_attachments_only: emails = [e for e in emails if e.get("has_attachments")] @@ -3015,7 +3106,7 @@ def setup_email_routes(): logger.debug(f"thread parse on read failed: {_pe}") cached_turns = None - return { + result = { "uid": uid, "folder": folder, "message_id": message_id.strip(), @@ -3039,6 +3130,8 @@ def setup_email_routes(): "thread_turns": cached_turns, "sender_signature": cached_sender_sig, } + _attach_email_urgency(owner, account_id, folder, [result]) + return result except Exception as e: logger.error(f"Failed to read email {uid}: {e}") return {"error": "Mail operation failed"} @@ -3082,6 +3175,7 @@ def setup_email_routes(): _asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner)) except RuntimeError: pass + _attach_email_urgency(owner, account_id, folder, [cached]) return cached if not full: persisted = _email_preview_cache_get(owner, account_id, folder, uid) @@ -3092,8 +3186,11 @@ def setup_email_routes(): _asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner)) except RuntimeError: pass + _attach_email_urgency(owner, account_id, folder, [persisted]) return persisted result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full) + if result and not result.get("error"): + _attach_email_urgency(owner, account_id, folder, [result]) if result and not result.get("error"): _read_cache_put(ck, result) if not full: @@ -5491,6 +5588,76 @@ def setup_email_routes(): # Exactly one row has is_default=True; that account is used when callers # don't specify an account_id. + @router.get("/urgency-levels") + async def get_email_urgency_levels( + include_inactive: bool = Query(False), + owner: str = Depends(require_user), + ): + return {"levels": list_email_urgency_levels(owner, include_inactive=bool(include_inactive))} + + @router.post("/urgency-levels") + async def create_email_urgency_level(payload: dict, owner: str = Depends(require_user)): + level = save_email_urgency_level(owner, payload or {}) + return {"success": True, "level": level, "levels": list_email_urgency_levels(owner)} + + @router.put("/urgency-levels/{slug}") + async def update_email_urgency_level(slug: str, payload: dict, owner: str = Depends(require_user)): + level = save_email_urgency_level(owner, payload or {}, existing_slug=slug) + return {"success": True, "level": level, "levels": list_email_urgency_levels(owner)} + + @router.delete("/urgency-levels/{slug}") + async def delete_email_urgency_level(slug: str, owner: str = Depends(require_user)): + result = archive_email_urgency_level(owner, slug) + _invalidate_list_cache() + return {**result, "levels": list_email_urgency_levels(owner)} + + @router.put("/urgency-assignment") + async def set_email_urgency_assignment(payload: dict, owner: str = Depends(require_user)): + payload = payload or {} + folder = str(payload.get("folder") or "INBOX") + account_id = _effective_email_account_id(payload.get("account_id"), owner) + urgency_slug = normalize_email_urgency_slug(payload.get("urgency_slug") or payload.get("slug") or "") + if not urgency_slug: + result = clear_email_urgency_assignment( + owner=owner, + account_id=account_id, + message_id=str(payload.get("message_id") or ""), + uid=str(payload.get("uid") or ""), + folder=folder, + ) + _invalidate_list_cache(account_id, folder) + return result + urgency = upsert_email_urgency_assignment( + owner=owner, + account_id=account_id, + message_id=str(payload.get("message_id") or ""), + uid=str(payload.get("uid") or ""), + folder=folder, + urgency_slug=urgency_slug, + reason=str(payload.get("reason") or ""), + confidence=payload.get("confidence"), + source=str(payload.get("source") or "manual"), + subject=str(payload.get("subject") or ""), + sender=str(payload.get("sender") or ""), + ) + _invalidate_list_cache(account_id, folder) + return {"success": True, "urgency": urgency} + + @router.delete("/urgency-assignment") + async def delete_email_urgency_assignment(payload: dict, owner: str = Depends(require_user)): + payload = payload or {} + folder = str(payload.get("folder") or "INBOX") + account_id = _effective_email_account_id(payload.get("account_id"), owner) + result = clear_email_urgency_assignment( + owner=owner, + account_id=account_id, + message_id=str(payload.get("message_id") or ""), + uid=str(payload.get("uid") or ""), + folder=folder, + ) + _invalidate_list_cache(account_id, folder) + return result + @router.get("/accounts") async def list_email_accounts(owner: str = Depends(require_user)): """List all email accounts with credentials masked.""" diff --git a/src/builtin_actions.py b/src/builtin_actions.py index 68817467f..a21d87208 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -2280,7 +2280,10 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: # classified items; message_id lives on the cached verdict so this is cheap. try: import sqlite3 as _sql3 - from routes.email_helpers import SCHEDULED_DB, _init_scheduled_db + from routes.email_helpers import ( + SCHEDULED_DB, _init_scheduled_db, + email_urgency_slug_for_score, email_urgency_message_key, + ) from datetime import datetime as _dt2 _init_scheduled_db() _conn = _sql3.connect(SCHEDULED_DB) @@ -2307,6 +2310,38 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: # _key is ":" — extract uid for the row. _acc_id, _uid_only = (_key.split(":", 1) + [""])[:2] _owner_key = owner or "" + try: + _urgency_slug = email_urgency_slug_for_score(_owner_key, _score) + _message_key = email_urgency_message_key(_msg_id, "INBOX", _uid_only) + if _urgency_slug and _message_key: + _now_assign = _dt2.utcnow().isoformat() + _conn.execute( + """ + INSERT INTO email_urgency_assignments + (owner, account_id, message_key, message_id, uid, folder, urgency_slug, + confidence, reason, source, subject, sender, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'INBOX', ?, ?, ?, 'classifier', ?, ?, ?, ?) + ON CONFLICT(owner, account_id, message_key) DO UPDATE SET + message_id=excluded.message_id, + uid=excluded.uid, + folder=excluded.folder, + urgency_slug=excluded.urgency_slug, + confidence=excluded.confidence, + reason=excluded.reason, + source=excluded.source, + subject=excluded.subject, + sender=excluded.sender, + updated_at=excluded.updated_at + """, + ( + _owner_key, _acc_id, _message_key, _msg_id, _uid_only, _urgency_slug, + min(1.0, max(0.0, float(_score or 0) / 3.0)), + _v.get("reason", ""), _v.get("subject", ""), _v.get("from", ""), + _now_assign, _now_assign, + ), + ) + except Exception as _ue: + logger.debug(f"urgency: assignment write skipped for {_key}: {_ue}") _row = _conn.execute( "SELECT tags FROM email_tags WHERE message_id=? AND owner=? AND account_id=?", (_msg_id, _owner_key, _acc_id), diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 6a0d3e294..a84e2a8c8 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -521,6 +521,331 @@ document.addEventListener('odysseus:email-filter-tag', (e) => { _applyTagFilterFromPill(e.detail?.tag); }); +const _URGENCY_ICON = ''; + +function _normalizeUrgencySlug(value) { + return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); +} + +function _urgencyLevelBySlug(slug) { + const key = _normalizeUrgencySlug(slug); + return (state._libUrgencyLevels || []).find(level => _normalizeUrgencySlug(level?.slug) === key) || null; +} + +function _urgencyColorValue(color) { + const c = String(color || '').trim().toLowerCase(); + if (/^#[0-9a-f]{3}([0-9a-f]{3})?$/.test(c)) return c; + if (c === 'red' || c === 'urgent') return 'var(--red, #e56b75)'; + if (c === 'orange' || c === 'amber') return 'var(--accent-warn, #d99a52)'; + if (c === 'blue' || c === 'info') return 'var(--accent-primary, #83c7e8)'; + if (c === 'green') return 'var(--green, #7ac47f)'; + return 'var(--fg-muted, var(--fg))'; +} + +function _emailUrgencyFromMessage(em) { + const urgency = em?.urgency && typeof em.urgency === 'object' ? em.urgency : null; + if (!urgency?.slug) return null; + const level = _urgencyLevelBySlug(urgency.slug) || urgency; + return { ...level, ...urgency }; +} + +function _emailUrgencyPillHtml(em) { + const urgency = _emailUrgencyFromMessage(em); + if (!urgency) return ''; + const slug = _normalizeUrgencySlug(urgency.slug); + if ((slug === 'ignore' || Number(urgency.rank || 0) <= 0) && urgency.source !== 'manual') return ''; + const label = urgency.name || slug.replace(/-/g, ' '); + const color = _urgencyColorValue(urgency.color); + const title = urgency.reason ? `${label}: ${urgency.reason}` : `Show ${label} emails`; + return ``; +} + +function _emailReaderUrgencyRowHtml(em) { + const pill = _emailUrgencyPillHtml(em); + return ``; +} + +function _applyUrgencyFilterFromPill(slug) { + const normalized = _normalizeUrgencySlug(slug); + if (!normalized) return; + const level = _urgencyLevelBySlug(normalized); + const value = `filter:urgency:${normalized}`; + const existingIdx = Array.isArray(state._libSearchPills) + ? state._libSearchPills.findIndex(p => p?.type === 'filter' && p.value === value) + : -1; + if (existingIdx >= 0) { + _removeSearchPillAt(existingIdx); + return; + } + _addSearchPill({ + type: 'filter', + value, + label: level?.name || normalized.replace(/-/g, ' '), + }); +} + +async function _loadUrgencyLevels({ force = false } = {}) { + if (!force && Array.isArray(state._libUrgencyLevels) && state._libUrgencyLevels.length) { + return state._libUrgencyLevels; + } + try { + const res = await fetch(`${API_BASE}/api/email/urgency-levels`, { credentials: 'same-origin' }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.detail || data?.error || `HTTP ${res.status}`); + state._libUrgencyLevels = Array.isArray(data.levels) ? data.levels : []; + } catch (err) { + console.error('Failed to load urgency levels:', err); + state._libUrgencyLevels = []; + } + _syncUrgencyFilterOptions(); + return state._libUrgencyLevels; +} + +function _syncUrgencyFilterOptions() { + const sel = document.getElementById('email-lib-filter'); + if (!sel) return; + let group = sel.querySelector('optgroup[data-email-urgency-options]'); + if (!group) { + group = document.createElement('optgroup'); + group.label = 'Urgency'; + group.dataset.emailUrgencyOptions = '1'; + sel.appendChild(group); + } + const levels = (state._libUrgencyLevels || []) + .filter(level => level && level.active !== false) + .sort((a, b) => Number(b.rank || 0) - Number(a.rank || 0) || String(a.name || '').localeCompare(String(b.name || ''))); + group.innerHTML = levels.map(level => { + const slug = _normalizeUrgencySlug(level.slug); + if (!slug) return ''; + return ``; + }).join(''); + _refreshFilterPickerMenu(); + _renderFilterPickerCurrent(); +} + +async function _setEmailUrgency(em, urgencySlug) { + const folder = (em && em.folder) || state._libFolder || 'INBOX'; + const slug = _normalizeUrgencySlug(urgencySlug); + const payload = { + uid: String(em?.uid || ''), + message_id: em?.message_id || '', + folder, + account_id: state._libAccountId || '', + urgency_slug: slug, + source: 'manual', + subject: em?.subject || '', + sender: em?.from_address || em?.from_name || '', + }; + const res = await fetch(`${API_BASE}/api/email/urgency-assignment`, { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) throw new Error(data?.detail || data?.error || 'Failed to update urgency'); + const next = data.urgency || null; + const applyOne = (target) => { + if (!target) return; + if (next) target.urgency = next; + else delete target.urgency; + }; + applyOne(em); + for (const item of state._libEmails || []) { + if (String(item.uid) === String(em?.uid)) applyOne(item); + } + _libCacheWriteBack(); + _refreshEmailUrgencyDom(em); + return next; +} + +function _refreshEmailUrgencyDom(em) { + const uid = String(em?.uid || ''); + const card = uid ? document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(uid)}"]`) : null; + if (card && !card.classList.contains('email-card-expanded')) { + _renderGrid(); + return; + } + if (card) { + const row = card.querySelector('.email-card-titlerow'); + row?.querySelector('.email-urgency-pill')?.remove(); + const html = _emailUrgencyPillHtml(em); + if (html) { + const tags = row?.querySelector('.email-tags'); + if (tags) tags.insertAdjacentHTML('beforebegin', html); + else row?.querySelector('.memory-item-title')?.insertAdjacentHTML('afterend', html); + } + _wireUrgencyPillClicks(card); + } + document.querySelectorAll(`.email-card-reader[data-email-uid="${CSS.escape(uid)}"]`).forEach(reader => { + const target = reader.querySelector('.email-reader-urgency-slot'); + if (target) { + target.innerHTML = _emailUrgencyPillHtml(em); + const row = target.closest('.email-reader-urgency-row'); + if (row) row.style.display = target.innerHTML ? '' : 'none'; + _wireUrgencyPillClicks(target); + } + }); +} + +function _wireUrgencyPillClicks(root) { + root?.querySelectorAll?.('[data-email-filter-urgency]').forEach(btn => { + if (btn.dataset.emailUrgencyBound === '1') return; + btn.dataset.emailUrgencyBound = '1'; + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + _applyUrgencyFilterFromPill(btn.dataset.emailFilterUrgency); + }); + }); +} + +async function _showUrgencyManager() { + await _loadUrgencyLevels({ force: true }); + document.querySelectorAll('.email-urgency-manager-overlay').forEach(el => el.remove()); + const overlay = document.createElement('div'); + overlay.className = 'email-urgency-manager-overlay'; + overlay.style.zIndex = String(topPortalZ()); + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + const form = overlay.querySelector('[data-form]'); + const list = overlay.querySelector('[data-level-list]'); + const fields = form.elements; + + const fill = (level = null) => { + form.dataset.editingSlug = level?.slug || ''; + fields.name.value = level?.name || ''; + fields.description.value = level?.description || ''; + fields.examples.value = Array.isArray(level?.examples) ? level.examples.join('\n') : ''; + fields.rank.value = Number.isFinite(Number(level?.rank)) ? String(level.rank) : '0'; + fields.color.value = level?.color || 'muted'; + fields.notify.checked = !!level?.notify; + overlay.querySelector('[data-act="delete"]').disabled = !level?.slug; + fields.name.focus(); + }; + + const renderList = () => { + const levels = (state._libUrgencyLevels || []) + .filter(level => level && level.active !== false) + .sort((a, b) => Number(b.rank || 0) - Number(a.rank || 0) || String(a.name || '').localeCompare(String(b.name || ''))); + list.innerHTML = levels.map(level => { + const slug = _normalizeUrgencySlug(level.slug); + const active = slug === form.dataset.editingSlug ? ' active' : ''; + return ``; + }).join(''); + list.querySelectorAll('[data-slug]').forEach(btn => { + btn.addEventListener('click', () => { + const level = _urgencyLevelBySlug(btn.dataset.slug); + fill(level); + renderList(); + }); + }); + }; + + overlay.querySelector('[data-act="close"]').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) overlay.remove(); }); + overlay.querySelector('[data-act="new"]').addEventListener('click', () => { fill(null); renderList(); }); + overlay.querySelector('[data-act="delete"]').addEventListener('click', async () => { + const slug = form.dataset.editingSlug; + const level = _urgencyLevelBySlug(slug); + if (!slug || !level) return; + const ok = await styledConfirm(`Archive urgency level "${level.name || slug}"? Existing assignments using it will be cleared.`, { + confirmText: 'Archive', + cancelText: 'Cancel', + danger: true, + }); + if (!ok) return; + const res = await fetch(`${API_BASE}/api/email/urgency-levels/${encodeURIComponent(slug)}`, { + method: 'DELETE', + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data?.detail || data?.error || 'Failed to archive urgency level'); + return; + } + state._libUrgencyLevels = Array.isArray(data.levels) ? data.levels : []; + _syncUrgencyFilterOptions(); + fill(null); + renderList(); + _loadEmailsFresh(); + }); + form.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const name = fields.name.value.trim(); + if (!name) { fields.name.focus(); return; } + const payload = { + name, + description: fields.description.value.trim(), + examples: fields.examples.value.split(/\r?\n/).map(x => x.trim()).filter(Boolean), + rank: Number(fields.rank.value || 0), + color: fields.color.value || 'muted', + notify: !!fields.notify.checked, + }; + const slug = form.dataset.editingSlug || ''; + const res = await fetch(slug + ? `${API_BASE}/api/email/urgency-levels/${encodeURIComponent(slug)}` + : `${API_BASE}/api/email/urgency-levels`, { + method: slug ? 'PUT' : 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.success === false) { + showToast(data?.detail || data?.error || 'Failed to save urgency level'); + return; + } + state._libUrgencyLevels = Array.isArray(data.levels) ? data.levels : []; + _syncUrgencyFilterOptions(); + fill(data.level || null); + renderList(); + _renderGrid(); + }); + fill((state._libUrgencyLevels || [])[0] || null); + renderList(); +} + function _emailTagPillHtml(tag, em) { const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-'); if (!normalized) return ''; @@ -2402,6 +2727,7 @@ export function openEmailLibrary(opts = {}) { + + `; + }).join(''); +} + function _initFilterPicker() { const sel = document.getElementById('email-lib-filter'); const picker = document.getElementById('email-filter-picker'); @@ -4913,6 +5290,13 @@ function _createCard(em) { titleRow.appendChild(att); } + const urgencyHtml = _emailUrgencyPillHtml(em); + if (urgencyHtml) { + const wrap = document.createElement('span'); + wrap.innerHTML = urgencyHtml; + titleRow.appendChild(wrap.firstElementChild); + } + const tags = state._libShowTags ? _visibleEmailTagsForRender(em) : []; if (state._libShowTags && (tags.length || em.is_spam_verdict)) { const tagWrap = document.createElement('span'); @@ -5096,6 +5480,7 @@ function _createCard(em) { await _toggleCardPreview(card, em); }); + _wireUrgencyPillClicks(card); return card; } @@ -5305,6 +5690,7 @@ async function _toggleCardPreview(card, em) { ${data.to ? `` : ''} ${data.cc ? `` : ''} ` : ''} + ${_emailReaderUrgencyRowHtml({ ...em, ...data })} ` : ''} + ${_emailReaderUrgencyRowHtml({ ...em, ...data })} ` : ''} + ${_emailReaderUrgencyRowHtml({ ...em, ...data })}