This commit is contained in:
Matyas Gosztonyi 2026-08-04 14:15:59 +02:00 committed by GitHub
commit c921d7101a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1512 additions and 9 deletions

View file

@ -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,

View file

@ -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."""

View file

@ -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 "<account_id>:<uid>" — 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),

View file

@ -521,6 +521,331 @@ document.addEventListener('odysseus:email-filter-tag', (e) => {
_applyTagFilterFromPill(e.detail?.tag);
});
const _URGENCY_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
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 `<button type="button" class="email-urgency-pill" data-email-filter-urgency="${_esc(slug)}" title="${_esc(title)}" style="--email-urgency-color:${_esc(color)};"><span class="email-urgency-dot" aria-hidden="true"></span>${_esc(label)}</button>`;
}
function _emailReaderUrgencyRowHtml(em) {
const pill = _emailUrgencyPillHtml(em);
return `<div class="email-reader-meta-row email-reader-urgency-row" style="${pill ? '' : 'display:none;'}"><strong>Urgency:</strong><span class="email-reader-urgency-slot">${pill}</span></div>`;
}
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 `<option value="urgency:${_esc(slug)}">${_esc(level.name || slug.replace(/-/g, ' '))}</option>`;
}).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 = `
<div class="modal-content email-urgency-manager" role="dialog" aria-modal="true" aria-labelledby="email-urgency-manager-title">
<div class="modal-header">
<h4 id="email-urgency-manager-title">Urgency</h4>
<button class="close-btn" type="button" data-act="close">&#x2716;</button>
</div>
<div class="email-urgency-manager-body">
<div class="email-urgency-level-list" data-level-list></div>
<form class="email-urgency-form" data-form>
<label>Name<input class="memory-search-input" name="name" maxlength="80" required></label>
<label>Description<textarea class="memory-search-input" name="description" rows="3" maxlength="600"></textarea></label>
<label>Examples<textarea class="memory-search-input" name="examples" rows="3" placeholder="One example per line"></textarea></label>
<div class="email-urgency-form-grid">
<label>Rank<input class="memory-search-input" name="rank" type="number" step="1" min="-1000" max="1000"></label>
<label>Color
<select class="memory-sort-select" name="color">
<option value="red">Red</option>
<option value="orange">Orange</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="muted">Muted</option>
</select>
</label>
</div>
<label class="email-urgency-check"><input type="checkbox" name="notify"> Notify when assigned by scanner</label>
<div class="email-urgency-actions">
<button class="memory-toolbar-btn" type="button" data-act="new">New</button>
<button class="memory-toolbar-btn" type="button" data-act="delete">Archive</button>
<button class="memory-toolbar-btn active" type="submit">Save</button>
</div>
</form>
</div>
</div>
`;
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 `<button type="button" class="email-urgency-level-row${active}" data-slug="${_esc(slug)}">
<span class="email-urgency-dot" style="background:${_esc(_urgencyColorValue(level.color))}"></span>
<span class="email-urgency-level-main">
<span class="email-urgency-level-name">${_esc(level.name || slug)}</span>
<span class="email-urgency-level-desc">${_esc(level.description || '')}</span>
</span>
<span class="email-urgency-level-rank">${Number(level.rank || 0)}</span>
</button>`;
}).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 = {}) {
<option value="tag:travel">Travel</option>
<option value="tag:spam">Spam</option>
</optgroup>
<optgroup label="Urgency" data-email-urgency-options></optgroup>
</select>
<div class="email-filter-picker" id="email-filter-picker" style="flex:1;min-width:0;position:relative;">
<button type="button" class="email-filter-btn" id="email-filter-btn" aria-haspopup="listbox" aria-expanded="false">
@ -2410,6 +2736,9 @@ export function openEmailLibrary(opts = {}) {
</button>
<div class="email-filter-menu" id="email-filter-menu" role="listbox" hidden></div>
</div>
<button class="memory-toolbar-btn email-filter-refresh-btn email-urgency-manage-btn" id="email-urgency-manage-btn" title="Manage urgency levels">
${_URGENCY_ICON}
</button>
<button class="memory-toolbar-btn email-filter-select-btn" id="email-lib-select-btn"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
<button class="memory-toolbar-btn email-filter-refresh-btn" id="email-lib-refresh-btn" title="Refresh">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>
@ -2593,6 +2922,10 @@ export function openEmailLibrary(opts = {}) {
_renderFilterPickerCurrent();
});
_initFilterPicker();
document.getElementById('email-urgency-manage-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation();
_showUrgencyManager();
});
document.getElementById('email-attach-btn')?.addEventListener('click', () => {
const btn = document.getElementById('email-attach-btn');
state._libHasAttachments = !state._libHasAttachments;
@ -2933,6 +3266,7 @@ export function openEmailLibrary(opts = {}) {
// otherwise waited on `/accounts` before even trying the cheap indexed list.
(async () => {
await _loadAccounts();
await _loadUrgencyLevels({ force: true });
_loadFolders();
_loadEmailReminderBellVisibility();
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
@ -3484,10 +3818,26 @@ const _LIB_FILTER_OPTIONS = [
{ value: 'filter:tag:spam', label: 'Spam', keywords: ['spam', 'junk'] },
];
function _libFilterOptions() {
const urgency = (state._libUrgencyLevels || [])
.filter(level => level && level.active !== false)
.map(level => {
const slug = _normalizeUrgencySlug(level.slug);
const label = level.name || slug.replace(/-/g, ' ');
return {
value: `filter:urgency:${slug}`,
label,
keywords: ['urgency', 'urgent', label.toLowerCase(), slug.replace(/-/g, ' ')].filter(Boolean),
};
});
return _LIB_FILTER_OPTIONS.concat(urgency);
}
function _libFilterIconFor(value) {
// value is 'filter:<X>' — strip prefix and reuse the existing icon map.
const v = String(value || '').replace(/^filter:/, '');
if (v === 'has-attachments') return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
if (v.startsWith('urgency:')) return _URGENCY_ICON;
return _EMAIL_FILTER_ICONS[v] || _EMAIL_FILTER_ICONS['all'];
}
@ -3507,7 +3857,7 @@ function _filterSuggestions(needle, limit = 10) {
// Filter / attachment matches first — typing 'unread' should surface
// the filter row before contact suggestions, since 'unread' isn't a
// person.
const filterMatches = _LIB_FILTER_OPTIONS
const filterMatches = _libFilterOptions()
.map(opt => ({ s: { kind: 'filter', value: opt.value, label: opt.label, icon: _libFilterIconFor(opt.value) }, score: _scoreFilterOption(opt, n) }))
.filter(x => x.score > 0);
const src = _libSuggestionCache || [];
@ -4265,6 +4615,7 @@ const _EMAIL_FILTER_ICONS = {
};
function _filterIcon(value) {
if (String(value || '').startsWith('urgency:')) return _URGENCY_ICON;
return _EMAIL_FILTER_ICONS[value] || _EMAIL_FILTER_ICONS['all'];
}
@ -4281,6 +4632,32 @@ function _renderFilterPickerCurrent() {
if (labelEl) labelEl.textContent = label;
}
function _refreshFilterPickerMenu() {
const sel = document.getElementById('email-lib-filter');
const menu = document.getElementById('email-filter-menu');
if (!sel || !menu) return;
const items = [];
for (const child of sel.children) {
if (child.tagName === 'OPTGROUP') {
items.push({ group: child.label });
for (const o of child.children) {
items.push({ value: o.value, label: o.textContent, group: child.label });
}
} else if (child.tagName === 'OPTION') {
items.push({ value: child.value, label: child.textContent });
}
}
menu.innerHTML = items.map(it => {
if (!it.value) {
return `<div class="email-filter-group">${it.group}</div>`;
}
return `<button type="button" role="option" class="email-filter-item" data-value="${it.value}">
<span class="email-filter-item-icon">${_filterIcon(it.value)}</span>
<span class="email-filter-item-label">${it.label}</span>
</button>`;
}).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 ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildRecipients(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildRecipients(data.cc)}</span></div>` : ''}
</div>` : ''}
${_emailReaderUrgencyRowHtml({ ...em, ...data })}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
@ -5436,6 +5822,7 @@ async function _toggleCardPreview(card, em) {
}
_wireRecipientChips(reader);
_wireUrgencyPillClicks(reader);
// Always stop bubbling so the card's click doesn't fire while reading.
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
} catch (e) {
@ -6935,6 +7322,7 @@ async function _openEmailAsTab(em, folder) {
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
</div>` : ''}
${_emailReaderUrgencyRowHtml({ ...em, ...data })}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
@ -6953,6 +7341,7 @@ async function _openEmailAsTab(em, folder) {
_markEmailReaderActive(reader);
reader.classList.remove('email-card-reader-loading');
_wireRecipientChips(reader);
_wireUrgencyPillClicks(reader);
_wireEmailAttachmentWrap(reader, useFolder);
_wireEmailInlineImages(reader);
_loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
@ -7090,6 +7479,7 @@ async function _openEmailWindow(em, folder) {
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${_chipsFor(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
</div>` : ''}
${_emailReaderUrgencyRowHtml({ ...em, ...data })}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
@ -7107,6 +7497,7 @@ async function _openEmailWindow(em, folder) {
`;
_markEmailReaderActive(bodyEl);
_wireRecipientChips(bodyEl);
_wireUrgencyPillClicks(bodyEl);
// Wire all the same action handlers the inline reader has.
_wireEmailAttachmentWrap(bodyEl, useFolder);
_wireEmailInlineImages(bodyEl);
@ -7464,6 +7855,11 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
icon: _translateIcon,
submenu: 'translate',
},
{
label: 'Urgency',
icon: _URGENCY_ICON,
submenu: 'urgency',
},
{ separator: true },
{
label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
@ -7640,6 +8036,10 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showEmailTranslateSubmenu(reader, dropdown);
return;
}
if (a.submenu === 'urgency') {
_showEmailUrgencySubmenu(em, dropdown);
return;
}
close();
a.action();
});
@ -7700,6 +8100,7 @@ function _showCardMenu(em, anchor) {
await _openEmailAsTab(em, folder);
}},
{ label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
{ label: 'Urgency', icon: _URGENCY_ICON, submenu: 'urgency' },
];
if (!isSentFolder) {
@ -7847,6 +8248,10 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
if (a.submenu === 'urgency') {
_showEmailUrgencySubmenu(em, dropdown);
return;
}
close();
a.action();
});
@ -8276,6 +8681,64 @@ function _hasMultipleRecipients(data) {
// _esc lives in ./emailLibrary/utils.js
function _showEmailUrgencySubmenu(em, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = '<span>Set urgency</span>';
parentDropdown.appendChild(header);
const currentSlug = _normalizeUrgencySlug(em?.urgency?.slug || '');
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 || '')));
for (const level of levels) {
const slug = _normalizeUrgencySlug(level.slug);
if (!slug) continue;
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
const checked = currentSlug === slug ? '<span style="margin-left:auto;opacity:0.7;">✓</span>' : '';
item.innerHTML = `<span class="dropdown-icon"><span class="email-urgency-dot" style="background:${_esc(_urgencyColorValue(level.color))}"></span></span><span>${_esc(level.name || slug)}</span>${checked}`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await _setEmailUrgency(em, slug);
parentDropdown.remove();
} catch (err) {
console.error(err);
showToast('Failed to update urgency');
}
});
parentDropdown.appendChild(item);
}
const clear = document.createElement('div');
clear.className = 'dropdown-item-compact';
clear.innerHTML = '<span class="dropdown-icon">×</span><span>Clear urgency</span>';
clear.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await _setEmailUrgency(em, '');
parentDropdown.remove();
} catch (err) {
console.error(err);
showToast('Failed to clear urgency');
}
});
parentDropdown.appendChild(clear);
const manage = document.createElement('div');
manage.className = 'dropdown-item-compact';
manage.innerHTML = `<span class="dropdown-icon">${_URGENCY_ICON}</span><span>Manage levels</span>`;
manage.addEventListener('click', (e) => {
e.stopPropagation();
parentDropdown.remove();
_showUrgencyManager();
});
parentDropdown.appendChild(manage);
}
function _showEmailTranslateSubmenu(reader, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');

View file

@ -26,6 +26,7 @@ export const state = {
_libSort: 'recent', // recent, unread, favorites
_libHasAttachments: false,
_libShowTags: localStorage.getItem('odysseus.email.showTags') !== '0',
_libUrgencyLevels: [],
_libLoading: false,
_docModule: null,
_onEmailClick: null,

View file

@ -32258,6 +32258,174 @@ body.doc-find-active mark.doc-find-mark.current {
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 36%, transparent);
}
.email-urgency-pill {
appearance: none;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--email-urgency-color, var(--accent, var(--red))) 18%, transparent);
color: var(--email-urgency-color, var(--accent, var(--red)));
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-family: inherit;
font-size: 9px;
font-weight: 700;
height: 17px;
line-height: 1;
max-width: 128px;
overflow: hidden;
padding: 2px 6px !important;
text-transform: uppercase;
white-space: nowrap;
}
.email-urgency-pill:hover { filter: brightness(1.12); }
.email-urgency-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--email-urgency-color, currentColor);
flex-shrink: 0;
}
.email-reader-urgency-row .email-urgency-pill {
height: 20px;
font-size: 10px;
}
.email-urgency-manager-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(0, 0, 0, 0.36);
}
.email-urgency-manager {
width: min(720px, 94vw);
max-height: min(720px, 90vh);
display: flex;
flex-direction: column;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.email-urgency-manager-body {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.2fr);
gap: 12px;
padding: 12px;
overflow: auto;
}
.email-urgency-level-list {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.email-urgency-level-row {
appearance: none;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--fg) 4%, transparent);
color: var(--fg);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
min-width: 0;
padding: 7px 9px;
text-align: left;
}
.email-urgency-level-row.active,
.email-urgency-level-row:hover {
border-color: color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
}
.email-urgency-level-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.email-urgency-level-name {
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-urgency-level-desc {
font-size: 10px;
opacity: 0.65;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-urgency-level-rank {
font-size: 10px;
opacity: 0.6;
flex-shrink: 0;
}
.email-urgency-form {
display: flex;
flex-direction: column;
gap: 9px;
}
.email-urgency-form label {
display: flex;
flex-direction: column;
gap: 5px;
color: var(--fg);
font-size: 11px;
font-weight: 700;
}
.email-urgency-form textarea {
resize: vertical;
min-height: 70px;
}
.email-urgency-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
}
.email-urgency-check {
flex-direction: row !important;
align-items: center;
gap: 6px !important;
font-weight: 600 !important;
line-height: 1.2;
min-height: 16px;
transform: none;
transition: none;
}
.email-urgency-check input[type="checkbox"] {
flex: 0 0 13px;
width: 13px;
height: 13px;
margin: 0;
transform: none;
transition: none;
}
.email-urgency-check:hover,
.email-urgency-check:hover input[type="checkbox"] {
transform: none;
}
.email-urgency-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
@media (max-width: 720px) {
.email-urgency-manager-body {
grid-template-columns: 1fr;
}
}
.email-tag {
display: inline-block;
font-size: 9px;
@ -33301,9 +33469,9 @@ body.doc-find-active mark.doc-find-mark.current {
}
.email-ai-reply-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Align select/new buttons in popup library toolbar */
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn, #email-lib-compose-btn { position: relative; top: -4px; }
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn, #email-urgency-manage-btn, #email-lib-compose-btn { position: relative; top: -4px; }
/* Select + Refresh sit slightly lower than Compose on desktop. */
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn { top: -2px; }
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn, #email-urgency-manage-btn { top: -2px; }
#email-lib-settings-btn { top: -1px; }
#email-lib-settings-btn svg { position: relative; top: 2px; }
/* New (Compose) button gets a slightly taller, slightly lower variant
@ -33319,7 +33487,7 @@ body.doc-find-active mark.doc-find-mark.current {
#email-lib-compose-btn svg { color: var(--accent, var(--red)); }
@media (max-width: 768px) {
/* On mobile they're 1px higher than desktop. */
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn { top: -3px; }
#email-lib-select-btn, #email-lib-refresh-btn, #email-lib-settings-btn, #email-urgency-manage-btn { top: -3px; }
#email-lib-settings-btn { top: -2px; }
}
@ -33625,8 +33793,6 @@ body.doc-find-active mark.doc-find-mark.current {
align-items: flex-start;
flex-direction: column;
}
}
/* On mobile, the "New" (compose) button deserves a touch-friendly size
so it stands out from the smaller toolbar buttons next to it. Lift
it slightly visually with a bigger SVG, padding, and font. */

View file

@ -0,0 +1,186 @@
import sqlite3
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 _patch_scheduled_db(monkeypatch, tmp_path):
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()
return db_path, email_helpers, email_routes
def test_email_urgency_levels_are_owner_scoped(tmp_path, monkeypatch):
_db_path, email_helpers, _email_routes = _patch_scheduled_db(monkeypatch, tmp_path)
alice_defaults = email_helpers.list_email_urgency_levels("alice")
assert [level["slug"] for level in alice_defaults] == ["urgent", "reply-soon", "info", "ignore"]
custom = email_helpers.save_email_urgency_level(
"alice",
{
"name": "Family critical",
"description": "Family or school pickup needs immediate attention",
"examples": ["School called", "Family member needs help"],
"rank": 350,
"color": "red",
"notify": True,
},
)
assert custom["slug"] == "family-critical"
assert custom["notify"] is True
assert "family-critical" in [level["slug"] for level in email_helpers.list_email_urgency_levels("alice")]
assert "family-critical" not in [level["slug"] for level in email_helpers.list_email_urgency_levels("bob")]
def test_email_urgency_assignments_are_owner_and_account_scoped(tmp_path, monkeypatch):
_db_path, email_helpers, _email_routes = _patch_scheduled_db(monkeypatch, tmp_path)
email_helpers.upsert_email_urgency_assignment(
owner="alice",
account_id="acct-1",
message_id="<shared@example.com>",
uid="101",
folder="INBOX",
urgency_slug="urgent",
reason="deadline today",
confidence=0.9,
source="manual",
subject="Shared",
sender="sender@example.com",
)
email_helpers.upsert_email_urgency_assignment(
owner="bob",
account_id="acct-1",
message_id="<shared@example.com>",
uid="201",
folder="INBOX",
urgency_slug="reply-soon",
reason="answer tomorrow",
confidence=0.5,
source="classifier",
)
alice = email_helpers.load_email_urgency_assignments(
owner="alice",
account_id="acct-1",
folder="INBOX",
emails=[{"message_id": "<shared@example.com>", "uid": "101", "folder": "INBOX"}],
)
bob = email_helpers.load_email_urgency_assignments(
owner="bob",
account_id="acct-1",
folder="INBOX",
emails=[{"message_id": "<shared@example.com>", "uid": "201", "folder": "INBOX"}],
)
wrong_account = email_helpers.load_email_urgency_assignments(
owner="alice",
account_id="acct-2",
folder="INBOX",
emails=[{"message_id": "<shared@example.com>", "uid": "101", "folder": "INBOX"}],
)
assert list(alice.values())[0]["slug"] == "urgent"
assert list(alice.values())[0]["reason"] == "deadline today"
assert list(bob.values())[0]["slug"] == "reply-soon"
assert wrong_account == {}
email_helpers.clear_email_urgency_assignment(
owner="alice",
account_id="acct-1",
message_id="<shared@example.com>",
uid="101",
folder="INBOX",
)
assert email_helpers.load_email_urgency_assignments(
owner="alice",
account_id="acct-1",
folder="INBOX",
emails=[{"message_id": "<shared@example.com>", "uid": "101", "folder": "INBOX"}],
) == {}
assert email_helpers.load_email_urgency_assignments(
owner="bob",
account_id="acct-1",
folder="INBOX",
emails=[{"message_id": "<shared@example.com>", "uid": "201", "folder": "INBOX"}],
)
@pytest.mark.asyncio
async def test_email_urgency_routes_manage_levels_and_assignments(tmp_path, monkeypatch):
db_path, _email_helpers, email_routes = _patch_scheduled_db(monkeypatch, tmp_path)
router = email_routes.setup_email_routes()
list_levels = _route_endpoint(router, "/api/email/urgency-levels", "GET")
create_level = _route_endpoint(router, "/api/email/urgency-levels", "POST")
update_level = _route_endpoint(router, "/api/email/urgency-levels/{slug}", "PUT")
delete_level = _route_endpoint(router, "/api/email/urgency-levels/{slug}", "DELETE")
set_assignment = _route_endpoint(router, "/api/email/urgency-assignment", "PUT")
initial = await list_levels(owner="alice")
assert [level["slug"] for level in initial["levels"]][:2] == ["urgent", "reply-soon"]
created = await create_level(
{"name": "Needs pickup", "description": "Family or school pickup", "rank": 360, "color": "orange"},
owner="alice",
)
assert created["level"]["slug"] == "needs-pickup"
updated = await update_level(
"needs-pickup",
{"name": "Family pickup", "description": "Family logistics", "rank": 365, "color": "red", "notify": True},
owner="alice",
)
assert updated["level"]["slug"] == "family-pickup"
assert updated["level"]["notify"] is True
assigned = await set_assignment(
{
"account_id": "acct-1",
"message_id": "<route@example.com>",
"uid": "11",
"folder": "INBOX",
"urgency_slug": "family-pickup",
"reason": "school pickup",
"subject": "Pickup",
},
owner="alice",
)
assert assigned["urgency"]["slug"] == "family-pickup"
cleared = await set_assignment(
{
"account_id": "acct-1",
"message_id": "<route@example.com>",
"uid": "11",
"folder": "INBOX",
"urgency_slug": "",
},
owner="alice",
)
assert cleared["cleared"] is True
deleted = await delete_level("family-pickup", owner="alice")
assert deleted["success"] is True
conn = sqlite3.connect(db_path)
try:
active = conn.execute(
"SELECT active FROM email_urgency_levels WHERE owner=? AND slug=?",
("alice", "family-pickup"),
).fetchone()[0]
finally:
conn.close()
assert active == 0