mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge 31d197e59e into 20e7fc0164
This commit is contained in:
commit
c921d7101a
7 changed files with 1512 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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">✖</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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
176
static/style.css
176
static/style.css
|
|
@ -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. */
|
||||
|
|
|
|||
186
tests/test_email_custom_urgency.py
Normal file
186
tests/test_email_custom_urgency.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue