mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
feat(email): add local custom labels
This commit is contained in:
parent
25c9e735ef
commit
7ea09fba20
6 changed files with 1243 additions and 37 deletions
|
|
@ -712,6 +712,51 @@ def _init_scheduled_db():
|
|||
# Best-effort — log via the module logger if available
|
||||
import logging as _lg
|
||||
_lg.getLogger(__name__).warning(f"email_tags owner-migration skipped: {_mig_e}")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_label_definitions (
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, account_id, slug)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_label_definitions_owner_account_active
|
||||
ON email_label_definitions(owner, account_id, active)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_label_assignments (
|
||||
owner TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
folder TEXT NOT NULL,
|
||||
message_key TEXT NOT NULL,
|
||||
message_id TEXT DEFAULT '',
|
||||
uid TEXT DEFAULT '',
|
||||
label_slug TEXT NOT NULL,
|
||||
subject TEXT DEFAULT '',
|
||||
sender TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, account_id, message_key, label_slug)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_label_assignments_filter
|
||||
ON email_label_assignments(owner, account_id, folder, label_slug)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_label_assignments_message_key
|
||||
ON email_label_assignments(owner, account_id, message_key)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_email_label_assignments_message_id
|
||||
ON email_label_assignments(owner, account_id, folder, message_id)
|
||||
""")
|
||||
_ensure_owner_scoped_email_cache_table(conn, "email_calendar_extractions", """
|
||||
CREATE TABLE IF NOT EXISTS email_calendar_extractions (
|
||||
message_id TEXT,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from pathlib import Path
|
|||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
|
|
@ -61,6 +61,7 @@ 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,
|
||||
_init_scheduled_db,
|
||||
)
|
||||
from routes.email_pollers import _start_poller
|
||||
|
||||
|
|
@ -250,6 +251,51 @@ def _email_tag_account_clause(account_id: str | None) -> tuple[str, list[str]]:
|
|||
|
||||
_VISIBLE_EMAIL_TAGS = {"urgent", "reply-soon", "action-needed", "calendar", "bills", "receipt", "travel"}
|
||||
_DONE_RESPONSE_TAGS = {"urgent", "reply-soon", "action-needed"}
|
||||
_RESERVED_EMAIL_LABEL_SLUGS = _VISIBLE_EMAIL_TAGS | {
|
||||
"all",
|
||||
"unread",
|
||||
"favorites",
|
||||
"undone",
|
||||
"reminders",
|
||||
"unanswered",
|
||||
"pending-30d",
|
||||
"pending_30d",
|
||||
"stale-30d",
|
||||
"stale_30d",
|
||||
"spam",
|
||||
"junk",
|
||||
"archive",
|
||||
"archived",
|
||||
"inbox",
|
||||
"sent",
|
||||
"trash",
|
||||
"drafts",
|
||||
"scheduled",
|
||||
}
|
||||
|
||||
|
||||
class EmailLabelCreateRequest(BaseModel):
|
||||
name: str
|
||||
color: str | None = None
|
||||
description: str | None = None
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class EmailLabelUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
color: str | None = None
|
||||
description: str | None = None
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
class EmailLabelMessageRequest(BaseModel):
|
||||
label: str
|
||||
uid: str | None = None
|
||||
folder: str = "INBOX"
|
||||
account_id: str | None = None
|
||||
message_id: str | None = None
|
||||
subject: str | None = None
|
||||
sender: str | None = None
|
||||
|
||||
|
||||
def _sanitize_visible_email_tags(tags, *, is_answered: bool = False) -> list[str]:
|
||||
|
|
@ -303,6 +349,175 @@ def _clear_done_response_tags(owner: str, account_id: str | None, folder: str, u
|
|||
logger.debug(f"clear done response tags skipped: {e}")
|
||||
|
||||
|
||||
def _model_dict(payload) -> dict:
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if hasattr(payload, "model_dump"):
|
||||
return payload.model_dump()
|
||||
if hasattr(payload, "dict"):
|
||||
return payload.dict()
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_email_label_name(value: str | None) -> str:
|
||||
name = re.sub(r"\s+", " ", str(value or "").strip())
|
||||
if not name:
|
||||
raise HTTPException(400, "Label name is required")
|
||||
if len(name) > 48:
|
||||
raise HTTPException(400, "Label name must be 48 characters or fewer")
|
||||
return name
|
||||
|
||||
|
||||
def _email_label_slug_from_name(name: str, *, allow_reserved: bool = False) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", str(name or "").strip().lower()).strip("-")
|
||||
if not slug:
|
||||
raise HTTPException(400, "Label name must include letters or numbers")
|
||||
slug = slug[:64].strip("-")
|
||||
if not slug:
|
||||
raise HTTPException(400, "Label name must include letters or numbers")
|
||||
if not allow_reserved and slug in _RESERVED_EMAIL_LABEL_SLUGS:
|
||||
raise HTTPException(400, "That label name is reserved")
|
||||
return slug
|
||||
|
||||
|
||||
def _normalize_email_label_color(value: str | None) -> str:
|
||||
color = str(value or "").strip()
|
||||
if not color:
|
||||
return ""
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?", color):
|
||||
raise HTTPException(400, "Label color must be a hex color")
|
||||
return color.lower()
|
||||
|
||||
|
||||
def _normalize_email_label_account(account_id: str | None, owner: str) -> str:
|
||||
account = str(account_id or "").strip()
|
||||
if account:
|
||||
_assert_owns_account(account, owner)
|
||||
return account
|
||||
|
||||
|
||||
def _email_label_row_to_dict(row) -> dict:
|
||||
return {
|
||||
"slug": row[0],
|
||||
"name": row[1],
|
||||
"color": row[2] or "",
|
||||
"description": row[3] or "",
|
||||
"active": bool(row[4]),
|
||||
"created_at": row[5],
|
||||
"updated_at": row[6],
|
||||
}
|
||||
|
||||
|
||||
def _email_label_message_key(folder: str | None, uid: str | None, message_id: str | None) -> str:
|
||||
mid = str(message_id or "").strip()
|
||||
if mid:
|
||||
return f"mid:{mid}"
|
||||
uid_s = str(uid or "").strip()
|
||||
if not uid_s:
|
||||
raise HTTPException(400, "Email uid or message_id is required")
|
||||
folder_s = str(folder or "INBOX").strip() or "INBOX"
|
||||
return f"uid:{folder_s}:{uid_s}"
|
||||
|
||||
|
||||
def _email_label_definition(owner: str, account_id: str | None, label: str):
|
||||
_init_scheduled_db()
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
slug = _email_label_slug_from_name(label)
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT slug, name, color, description, active, created_at, updated_at
|
||||
FROM email_label_definitions
|
||||
WHERE owner=? AND account_id=? AND slug=?
|
||||
""",
|
||||
(owner or "", account, slug),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _email_label_filter_matches(owner: str, account_id: str | None, folder: str, slug: str) -> tuple[list[str], list[str]]:
|
||||
_init_scheduled_db()
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
slug = _email_label_slug_from_name(slug)
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT a.message_id, a.uid, a.folder
|
||||
FROM email_label_assignments a
|
||||
JOIN email_label_definitions d
|
||||
ON d.owner=a.owner AND d.account_id=a.account_id AND d.slug=a.label_slug
|
||||
WHERE a.owner=? AND a.account_id=? AND a.label_slug=? AND d.active=1
|
||||
AND (a.message_id != '' OR a.folder=?)
|
||||
""",
|
||||
(owner or "", account, slug, folder),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
message_ids: list[str] = []
|
||||
uids: list[str] = []
|
||||
for mid, uid, row_folder in rows:
|
||||
mid_s = str(mid or "").strip()
|
||||
uid_s = str(uid or "").strip()
|
||||
if mid_s and mid_s not in message_ids:
|
||||
message_ids.append(mid_s)
|
||||
elif uid_s and str(row_folder or "") == str(folder or "") and uid_s not in uids:
|
||||
uids.append(uid_s)
|
||||
return message_ids, uids
|
||||
|
||||
|
||||
def _attach_custom_email_labels(owner: str, account_id: str | None, folder: str, emails: list[dict]) -> None:
|
||||
if not emails:
|
||||
return
|
||||
try:
|
||||
_init_scheduled_db()
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
keys = []
|
||||
key_by_email = {}
|
||||
for e in emails:
|
||||
email_folder = e.get("folder") or folder
|
||||
try:
|
||||
key = _email_label_message_key(email_folder, e.get("uid"), e.get("message_id"))
|
||||
except HTTPException:
|
||||
continue
|
||||
key_by_email[id(e)] = key
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
return
|
||||
placeholders = ",".join("?" * len(keys))
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT a.message_key, d.slug, d.name, d.color, d.description
|
||||
FROM email_label_assignments a
|
||||
JOIN email_label_definitions d
|
||||
ON d.owner=a.owner AND d.account_id=a.account_id AND d.slug=a.label_slug
|
||||
WHERE a.owner=? AND a.account_id=? AND d.active=1
|
||||
AND a.message_key IN ({placeholders})
|
||||
ORDER BY lower(d.name), d.slug
|
||||
""",
|
||||
(owner or "", account, *keys),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
by_key: dict[str, list[dict]] = {}
|
||||
for key, slug, name, color, desc in rows:
|
||||
by_key.setdefault(str(key), []).append({
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"color": color or "",
|
||||
"description": desc or "",
|
||||
})
|
||||
for e in emails:
|
||||
e["labels"] = by_key.get(key_by_email.get(id(e)), [])
|
||||
except Exception as e:
|
||||
logger.debug(f"custom email label 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:
|
||||
|
|
@ -1642,6 +1857,228 @@ def setup_email_routes():
|
|||
_POOL_HOOKS["connect"] = _pooled_connect
|
||||
_POOL_HOOKS["release"] = _pooled_release
|
||||
|
||||
@router.get("/labels")
|
||||
async def list_email_labels(
|
||||
account_id: str | None = Query(None),
|
||||
include_inactive: bool = Query(False),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
_init_scheduled_db()
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
active_clause = "" if include_inactive else "AND active=1"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT slug, name, color, description, active, created_at, updated_at
|
||||
FROM email_label_definitions
|
||||
WHERE owner=? AND account_id=? {active_clause}
|
||||
ORDER BY lower(name), slug
|
||||
""",
|
||||
(owner or "", account),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"labels": [_email_label_row_to_dict(r) for r in rows]}
|
||||
|
||||
@router.post("/labels")
|
||||
async def create_email_label(
|
||||
payload: EmailLabelCreateRequest,
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
data = _model_dict(payload)
|
||||
name = _normalize_email_label_name(data.get("name"))
|
||||
slug = _email_label_slug_from_name(name)
|
||||
color = _normalize_email_label_color(data.get("color"))
|
||||
description = str(data.get("description") or "").strip()[:240]
|
||||
account = _normalize_email_label_account(data.get("account_id"), owner)
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
_init_scheduled_db()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO email_label_definitions
|
||||
(owner, account_id, slug, name, color, description, active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(owner, account_id, slug) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
color=excluded.color,
|
||||
description=excluded.description,
|
||||
active=1,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(owner or "", account, slug, name, color, description, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT slug, name, color, description, active, created_at, updated_at
|
||||
FROM email_label_definitions
|
||||
WHERE owner=? AND account_id=? AND slug=?
|
||||
""",
|
||||
(owner or "", account, slug),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
_invalidate_list_cache(account, None)
|
||||
return {"success": True, "label": _email_label_row_to_dict(row)}
|
||||
|
||||
@router.patch("/labels/{slug}")
|
||||
async def update_email_label(
|
||||
slug: str,
|
||||
payload: EmailLabelUpdateRequest,
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
data = _model_dict(payload)
|
||||
label_slug = _email_label_slug_from_name(slug)
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
updates = []
|
||||
params = []
|
||||
if data.get("name") is not None:
|
||||
updates.append("name=?")
|
||||
params.append(_normalize_email_label_name(data.get("name")))
|
||||
if data.get("color") is not None:
|
||||
updates.append("color=?")
|
||||
params.append(_normalize_email_label_color(data.get("color")))
|
||||
if data.get("description") is not None:
|
||||
updates.append("description=?")
|
||||
params.append(str(data.get("description") or "").strip()[:240])
|
||||
if data.get("active") is not None:
|
||||
updates.append("active=?")
|
||||
params.append(1 if bool(data.get("active")) else 0)
|
||||
if not updates:
|
||||
raise HTTPException(400, "No label fields to update")
|
||||
updates.append("updated_at=?")
|
||||
params.append(datetime.utcnow().isoformat() + "Z")
|
||||
_init_scheduled_db()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
f"""
|
||||
UPDATE email_label_definitions
|
||||
SET {', '.join(updates)}
|
||||
WHERE owner=? AND account_id=? AND slug=?
|
||||
""",
|
||||
(*params, owner or "", account, label_slug),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Label not found")
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT slug, name, color, description, active, created_at, updated_at
|
||||
FROM email_label_definitions
|
||||
WHERE owner=? AND account_id=? AND slug=?
|
||||
""",
|
||||
(owner or "", account, label_slug),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
_invalidate_list_cache(account, None)
|
||||
return {"success": True, "label": _email_label_row_to_dict(row)}
|
||||
|
||||
@router.delete("/labels/{slug}")
|
||||
async def delete_email_label(
|
||||
slug: str,
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
label_slug = _email_label_slug_from_name(slug)
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
_init_scheduled_db()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE email_label_definitions
|
||||
SET active=0, updated_at=?
|
||||
WHERE owner=? AND account_id=? AND slug=?
|
||||
""",
|
||||
(datetime.utcnow().isoformat() + "Z", owner or "", account, label_slug),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_invalidate_list_cache(account, None)
|
||||
return {"success": True, "deleted": cur.rowcount > 0}
|
||||
|
||||
@router.post("/labels/message")
|
||||
async def add_email_label_to_message(
|
||||
payload: EmailLabelMessageRequest,
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
data = _model_dict(payload)
|
||||
folder = str(data.get("folder") or "INBOX").strip() or "INBOX"
|
||||
account = _normalize_email_label_account(data.get("account_id"), owner)
|
||||
row = _email_label_definition(owner, account, data.get("label") or "")
|
||||
if not row or not bool(row[4]):
|
||||
raise HTTPException(404, "Label not found")
|
||||
key = _email_label_message_key(folder, data.get("uid"), data.get("message_id"))
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
_init_scheduled_db()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO email_label_assignments
|
||||
(owner, account_id, folder, message_key, message_id, uid, label_slug, subject, sender, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner, account_id, message_key, label_slug) DO UPDATE SET
|
||||
folder=excluded.folder,
|
||||
message_id=excluded.message_id,
|
||||
uid=excluded.uid,
|
||||
subject=excluded.subject,
|
||||
sender=excluded.sender
|
||||
""",
|
||||
(
|
||||
owner or "",
|
||||
account,
|
||||
folder,
|
||||
key,
|
||||
str(data.get("message_id") or "").strip(),
|
||||
str(data.get("uid") or "").strip(),
|
||||
row[0],
|
||||
str(data.get("subject") or "").strip()[:300],
|
||||
str(data.get("sender") or "").strip()[:300],
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_invalidate_list_cache(account, None)
|
||||
return {"success": True, "label": _email_label_row_to_dict(row), "message_key": key}
|
||||
|
||||
@router.delete("/labels/message/{slug}")
|
||||
async def remove_email_label_from_message(
|
||||
slug: str,
|
||||
uid: str | None = Query(None),
|
||||
folder: str = Query("INBOX"),
|
||||
account_id: str | None = Query(None),
|
||||
message_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
account = _normalize_email_label_account(account_id, owner)
|
||||
label_slug = _email_label_slug_from_name(slug)
|
||||
key = _email_label_message_key(folder, uid, message_id)
|
||||
_init_scheduled_db()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
DELETE FROM email_label_assignments
|
||||
WHERE owner=? AND account_id=? AND message_key=? AND label_slug=?
|
||||
""",
|
||||
(owner or "", account, key, label_slug),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_invalidate_list_cache(account, None)
|
||||
return {"success": True, "removed": cur.rowcount}
|
||||
|
||||
def _fixture_email_file() -> Path:
|
||||
return Path(DATA_DIR) / "fixture_email_messages.json"
|
||||
|
||||
|
|
@ -1726,6 +2163,13 @@ def setup_email_routes():
|
|||
pass
|
||||
elif filter_ in {"favorites", "reminders"} or str(filter_).startswith("tag:"):
|
||||
rows = []
|
||||
elif str(filter_).startswith("label:"):
|
||||
_attach_custom_email_labels(owner, None, folder, rows)
|
||||
slug = _email_label_slug_from_name(str(filter_)[len("label:"):].strip())
|
||||
rows = [
|
||||
e for e in rows
|
||||
if any((lbl.get("slug") == slug) for lbl in (e.get("labels") or []))
|
||||
]
|
||||
else:
|
||||
pass
|
||||
total = len(rows)
|
||||
|
|
@ -1736,6 +2180,8 @@ def setup_email_routes():
|
|||
item = dict(e)
|
||||
item.pop("_fixture_body", None)
|
||||
visible.append(item)
|
||||
if not str(filter_ or "").startswith("label:"):
|
||||
_attach_custom_email_labels(owner, None, folder, visible)
|
||||
return {
|
||||
"emails": visible,
|
||||
"total": total,
|
||||
|
|
@ -1838,6 +2284,29 @@ 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("label:"):
|
||||
_label_name = filter_[len("label:"):].strip().lower()
|
||||
_label_message_ids, _label_uid_fallback = _email_label_filter_matches(owner, account_id, folder, _label_name)
|
||||
if not _label_message_ids and not _label_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(_label_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 _label_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
|
||||
|
|
@ -2144,10 +2613,18 @@ def setup_email_routes():
|
|||
logger.debug(f"email calendar event link attach skipped: {e}")
|
||||
|
||||
_hide_unlinked_calendar_tags(emails)
|
||||
_attach_custom_email_labels(owner, account_id, folder, 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("label:"):
|
||||
_final_label = _email_label_slug_from_name(filter_[len("label:"):].strip())
|
||||
emails = [
|
||||
e for e in emails
|
||||
if any((lbl.get("slug") == _final_label) for lbl in (e.get("labels") or []))
|
||||
]
|
||||
total = len(emails)
|
||||
|
||||
if has_attachments_only:
|
||||
emails = [e for e in emails if e.get("has_attachments")]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||
import { styledConfirm, styledPrompt, showToast, emptyStateIcon } from './ui.js';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1';
|
||||
import settingsModule from './settings.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
|
|
@ -517,10 +517,37 @@ function _applyTagFilterFromPill(tag) {
|
|||
});
|
||||
}
|
||||
|
||||
function _normalizeEmailLabelSlug(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
function _applyLabelFilterFromPill(label) {
|
||||
const slug = _normalizeEmailLabelSlug(label);
|
||||
if (!slug) return;
|
||||
const meta = (state._libLabels || []).find(l => l.slug === slug);
|
||||
const value = `filter:label:${slug}`;
|
||||
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: meta?.name || slug.replace(/-/g, ' '),
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:email-filter-tag', (e) => {
|
||||
_applyTagFilterFromPill(e.detail?.tag);
|
||||
});
|
||||
|
||||
document.addEventListener('odysseus:email-filter-label', (e) => {
|
||||
_applyLabelFilterFromPill(e.detail?.label);
|
||||
});
|
||||
|
||||
function _emailTagPillHtml(tag, em) {
|
||||
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
if (!normalized) return '';
|
||||
|
|
@ -534,14 +561,77 @@ function _emailTagPillHtml(tag, em) {
|
|||
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-email-filter-tag="${_esc(normalized)}" title="Show ${_esc(normalized)} emails">${_esc(normalized)}</button>`;
|
||||
}
|
||||
|
||||
function _emailTagGroupHtml(tags, em) {
|
||||
const visible = (Array.isArray(tags) ? tags : [])
|
||||
function _emailLabelPillHtml(label) {
|
||||
const slug = _normalizeEmailLabelSlug(label?.slug || label?.name);
|
||||
const name = String(label?.name || slug.replace(/-/g, ' ')).trim();
|
||||
if (!slug || !name) return '';
|
||||
const color = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/.test(label?.color || '') ? label.color : '';
|
||||
const style = color ? ` style="--email-label-color:${_esc(color)}"` : '';
|
||||
return `<button type="button" class="email-tag email-label-pill email-tag-clickable" data-email-filter-label="${_esc(slug)}" title="Show ${_esc(name)} emails"${style}><span class="email-label-dot"></span>${_esc(name)}</button>`;
|
||||
}
|
||||
|
||||
function _emailTagGroupHtml(tags, em, labels = []) {
|
||||
const visibleTags = (Array.isArray(tags) ? tags : [])
|
||||
.map(t => _emailTagPillHtml(t, em))
|
||||
.filter(Boolean);
|
||||
const visibleLabels = (Array.isArray(labels) ? labels : [])
|
||||
.map(l => _emailLabelPillHtml(l))
|
||||
.filter(Boolean);
|
||||
const visible = visibleTags.concat(visibleLabels);
|
||||
if (!visible.length) return '';
|
||||
if (visible.length === 1) return visible[0];
|
||||
const extra = visible.slice(1).map(html => `<span class="email-tag-extra">${html}</span>`).join('');
|
||||
return `${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" title="Show all tags">+${visible.length - 1}<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>`;
|
||||
const moreCount = visible.length - 1;
|
||||
return `${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" aria-label="Show ${moreCount} more tags" title="Show more tags"><span class="email-tags-more-count">+${moreCount}</span><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>`;
|
||||
}
|
||||
|
||||
function _wireEmailTagWrap(tagWrap) {
|
||||
if (!tagWrap || tagWrap.dataset.emailTagsWired === '1') return;
|
||||
tagWrap.dataset.emailTagsWired = '1';
|
||||
tagWrap.addEventListener('click', (ev) => {
|
||||
const calBtn = ev.target.closest('[data-calendar-event-uid]');
|
||||
const tagBtn = ev.target.closest('[data-email-filter-tag]');
|
||||
const labelBtn = ev.target.closest('[data-email-filter-label]');
|
||||
const moreBtn = ev.target.closest('[data-email-tags-more]');
|
||||
if (!calBtn && !tagBtn && !labelBtn && !moreBtn) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (moreBtn) {
|
||||
const expanded = tagWrap.classList.toggle('email-tags-expanded');
|
||||
moreBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
moreBtn.setAttribute('aria-label', expanded ? 'Collapse tags' : `Show ${tagWrap.querySelectorAll('.email-tag-extra').length} more tags`);
|
||||
moreBtn.title = expanded ? 'Collapse tags' : 'Show more tags';
|
||||
} else if (calBtn) _openCalendarEventFromEmail(calBtn.dataset.calendarEventUid);
|
||||
else if (tagBtn) _applyTagFilterFromPill(tagBtn.dataset.emailFilterTag);
|
||||
else if (labelBtn) _applyLabelFilterFromPill(labelBtn.dataset.emailFilterLabel);
|
||||
});
|
||||
}
|
||||
|
||||
function _buildEmailCardTagWrap(em) {
|
||||
const tags = state._libShowTags ? _visibleEmailTagsForRender(em) : [];
|
||||
const labels = state._libShowTags ? _visibleEmailLabelsForRender(em) : [];
|
||||
if (!state._libShowTags || (!tags.length && !labels.length && !em?.is_spam_verdict)) return null;
|
||||
const tagWrap = document.createElement('span');
|
||||
tagWrap.className = 'email-tags email-card-tags' + ((tags.length + labels.length) > 1 ? ' email-tags-collapsed' : '');
|
||||
tagWrap.innerHTML = _emailTagGroupHtml(tags, em, labels);
|
||||
if (em?.is_spam_verdict) {
|
||||
tagWrap.insertAdjacentHTML('beforeend', '<span class="email-tag email-tag-spam">spam</span>');
|
||||
}
|
||||
_wireEmailTagWrap(tagWrap);
|
||||
return tagWrap;
|
||||
}
|
||||
|
||||
function _refreshEmailCardTags(em, card) {
|
||||
const targetCard = card?.closest?.('.doclib-card') || document.querySelector(`.doclib-card[data-uid="${CSS.escape(String(em?.uid || ''))}"]`);
|
||||
const titleRow = targetCard?.querySelector?.('.email-card-titlerow');
|
||||
if (!targetCard?.isConnected || !titleRow) return false;
|
||||
titleRow.querySelector('.email-card-tags')?.remove();
|
||||
const tagWrap = _buildEmailCardTagWrap(em);
|
||||
if (!tagWrap) return true;
|
||||
const before = titleRow.querySelector('.email-card-done, .email-card-unread-dot, [data-unread-dot], .email-card-favorite, .email-card-nav-arrows');
|
||||
if (before) titleRow.insertBefore(tagWrap, before);
|
||||
else titleRow.appendChild(tagWrap);
|
||||
return true;
|
||||
}
|
||||
|
||||
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
|
||||
|
|
@ -552,6 +642,10 @@ function _visibleEmailTagsForRender(em) {
|
|||
return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
|
||||
}
|
||||
|
||||
function _visibleEmailLabelsForRender(em) {
|
||||
return Array.isArray(em?.labels) ? em.labels : [];
|
||||
}
|
||||
|
||||
function _clearDoneResponseTagsLocal(em) {
|
||||
if (!em || !Array.isArray(em.tags)) return;
|
||||
em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
|
||||
|
|
@ -2410,6 +2504,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-labels-manage-btn" id="email-labels-manage-btn" title="Manage custom labels" aria-label="Manage custom labels">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>
|
||||
</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>
|
||||
|
|
@ -2613,6 +2710,10 @@ export function openEmailLibrary(opts = {}) {
|
|||
document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } }));
|
||||
});
|
||||
}
|
||||
document.getElementById('email-labels-manage-btn')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_openLabelManager(e.currentTarget);
|
||||
});
|
||||
document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => {
|
||||
const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', {
|
||||
confirmText: 'Delete',
|
||||
|
|
@ -2933,6 +3034,7 @@ export function openEmailLibrary(opts = {}) {
|
|||
// otherwise waited on `/accounts` before even trying the cheap indexed list.
|
||||
(async () => {
|
||||
await _loadAccounts();
|
||||
await _loadLabels();
|
||||
_loadFolders();
|
||||
_loadEmailReminderBellVisibility();
|
||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||
|
|
@ -2978,6 +3080,250 @@ async function _loadAccounts({ force = false } = {}) {
|
|||
_refreshAccountUnreadHighlights().catch(() => {});
|
||||
}
|
||||
|
||||
async function _loadLabels() {
|
||||
try {
|
||||
const qs = state._libAccountId ? `?account_id=${encodeURIComponent(state._libAccountId)}` : '';
|
||||
const r = await fetch(`${API_BASE}/api/email/labels${qs}`, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
state._libLabels = Array.isArray(d.labels) ? d.labels : [];
|
||||
} catch (err) {
|
||||
state._libLabels = [];
|
||||
console.debug('Email labels load failed:', err);
|
||||
}
|
||||
_syncLabelFilterOptions();
|
||||
}
|
||||
|
||||
function _labelAccountQuery(prefix = '?') {
|
||||
return state._libAccountId ? `${prefix}account_id=${encodeURIComponent(state._libAccountId)}` : '';
|
||||
}
|
||||
|
||||
function _labelColorForName(name) {
|
||||
const palette = ['#60a5fa', '#4ade80', '#facc15', '#fb7185', '#a78bfa', '#2dd4bf'];
|
||||
const raw = String(name || '');
|
||||
let acc = 0;
|
||||
for (let i = 0; i < raw.length; i += 1) acc = (acc + raw.charCodeAt(i) * (i + 1)) % 997;
|
||||
return palette[acc % palette.length];
|
||||
}
|
||||
|
||||
async function _createEmailLabel(defaultName = '') {
|
||||
const name = await styledPrompt('Label name', {
|
||||
title: 'New label',
|
||||
defaultValue: defaultName,
|
||||
placeholder: 'Work, Family, Receipts...',
|
||||
confirmText: 'Create',
|
||||
maxLength: 48,
|
||||
});
|
||||
if (!name) return null;
|
||||
const body = {
|
||||
name,
|
||||
color: _labelColorForName(name),
|
||||
account_id: state._libAccountId || null,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/email/labels`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.success === false) {
|
||||
showToast(data?.detail || data?.error || 'Could not create label');
|
||||
return null;
|
||||
}
|
||||
await _loadLabels();
|
||||
showToast('Label created');
|
||||
return data.label;
|
||||
}
|
||||
|
||||
async function _renameEmailLabel(label) {
|
||||
const name = await styledPrompt('Label name', {
|
||||
title: 'Rename label',
|
||||
defaultValue: label?.name || '',
|
||||
confirmText: 'Rename',
|
||||
maxLength: 48,
|
||||
});
|
||||
if (!name) return;
|
||||
const qs = _labelAccountQuery('?');
|
||||
const res = await fetch(`${API_BASE}/api/email/labels/${encodeURIComponent(label.slug)}${qs}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.success === false) {
|
||||
showToast(data?.detail || data?.error || 'Could not rename label');
|
||||
return;
|
||||
}
|
||||
await _loadLabels();
|
||||
await _loadEmailsFresh();
|
||||
showToast('Label renamed');
|
||||
}
|
||||
|
||||
async function _deleteEmailLabel(label) {
|
||||
const ok = await styledConfirm(`Delete label "${label?.name || label?.slug}"? Existing email assignments will stop showing.`, {
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const qs = _labelAccountQuery('?');
|
||||
const res = await fetch(`${API_BASE}/api/email/labels/${encodeURIComponent(label.slug)}${qs}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.success === false) {
|
||||
showToast(data?.detail || data?.error || 'Could not delete label');
|
||||
return;
|
||||
}
|
||||
await _loadLabels();
|
||||
if (state._libFilter === `label:${label.slug}`) {
|
||||
state._libFilter = 'all';
|
||||
const filterEl = document.getElementById('email-lib-filter');
|
||||
if (filterEl) filterEl.value = 'all';
|
||||
state._libSearchPills = (state._libSearchPills || []).filter(p => p?.value !== `filter:label:${label.slug}`);
|
||||
_renderSearchPills();
|
||||
_renderFilterPickerCurrent();
|
||||
}
|
||||
await _loadEmailsFresh();
|
||||
showToast('Label deleted');
|
||||
}
|
||||
|
||||
function _emailLabelPayload(em, label) {
|
||||
const folder = em?.folder || state._libFolder || 'INBOX';
|
||||
return {
|
||||
label: label?.slug || label?.name || label,
|
||||
uid: String(em?.uid || ''),
|
||||
folder,
|
||||
account_id: state._libAccountId || null,
|
||||
message_id: em?.message_id || '',
|
||||
subject: em?.subject || '',
|
||||
sender: em?.from_address || em?.from_name || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function _refreshEmailLabelUi(em, opts = {}) {
|
||||
if (!opts?.preserveOpenReader) {
|
||||
_renderGrid();
|
||||
return;
|
||||
}
|
||||
const sourceCard = opts.card?.closest?.('.doclib-card') || opts.reader?.closest?.('.doclib-card') || null;
|
||||
if (sourceCard && _refreshEmailCardTags(em, sourceCard)) {
|
||||
return;
|
||||
}
|
||||
if (!String(em?.uid || '')) {
|
||||
_renderGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
_renderGrid();
|
||||
}
|
||||
|
||||
async function _toggleEmailLabel(em, label, shouldAdd, opts = {}) {
|
||||
if (!em || !label?.slug) return;
|
||||
const qs = _labelAccountQuery('&');
|
||||
try {
|
||||
if (shouldAdd) {
|
||||
const res = await fetch(`${API_BASE}/api/email/labels/message`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_emailLabelPayload(em, label)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const labels = Array.isArray(em.labels) ? em.labels.slice() : [];
|
||||
if (!labels.some(l => l.slug === label.slug)) labels.push(label);
|
||||
em.labels = labels;
|
||||
showToast('Label added');
|
||||
} else {
|
||||
const folder = em?.folder || state._libFolder || 'INBOX';
|
||||
const params = `${qs}&uid=${encodeURIComponent(em.uid || '')}&folder=${encodeURIComponent(folder)}&message_id=${encodeURIComponent(em.message_id || '')}`;
|
||||
const res = await fetch(`${API_BASE}/api/email/labels/message/${encodeURIComponent(label.slug)}?${params.replace(/^&/, '')}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
em.labels = (Array.isArray(em.labels) ? em.labels : []).filter(l => l.slug !== label.slug);
|
||||
showToast('Label removed');
|
||||
}
|
||||
await _refreshEmailLabelUi(em, opts);
|
||||
_libCacheWriteBack();
|
||||
} catch (err) {
|
||||
console.error('Email label toggle failed:', err);
|
||||
showToast('Could not update label');
|
||||
}
|
||||
}
|
||||
|
||||
function _openLabelManager(anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-label-manager-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:220px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:5px;font-size:12px;top:${rect.bottom + 4}px;left:${Math.max(8, Math.min(rect.left, window.innerWidth - 236))}px;`;
|
||||
const render = () => {
|
||||
const labels = state._libLabels || [];
|
||||
const rows = labels.map(label => `
|
||||
<div class="email-label-manager-row" data-label-slug="${_esc(label.slug)}">
|
||||
<button type="button" class="email-label-manager-filter" title="Filter by ${_esc(label.name)}">
|
||||
<span class="email-label-dot" style="--email-label-color:${_esc(label.color || '#60a5fa')}"></span>
|
||||
<span>${_esc(label.name)}</span>
|
||||
</button>
|
||||
<button type="button" class="email-label-manager-icon" data-label-edit="${_esc(label.slug)}" title="Rename label" aria-label="Rename label">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="email-label-manager-icon danger" data-label-delete="${_esc(label.slug)}" title="Delete label" aria-label="Delete label">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
dropdown.innerHTML = `
|
||||
<button type="button" class="dropdown-item-compact email-label-create-row" data-label-create>
|
||||
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg></span>
|
||||
<span>New label</span>
|
||||
</button>
|
||||
${labels.length ? '<div class="dropdown-divider"></div>' + rows : '<div class="email-label-empty">No labels yet</div>'}
|
||||
`;
|
||||
};
|
||||
render();
|
||||
dropdown.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const create = e.target.closest('[data-label-create]');
|
||||
if (create) {
|
||||
const label = await _createEmailLabel();
|
||||
if (label) render();
|
||||
return;
|
||||
}
|
||||
const edit = e.target.closest('[data-label-edit]');
|
||||
if (edit) {
|
||||
const label = (state._libLabels || []).find(l => l.slug === edit.dataset.labelEdit);
|
||||
if (label) {
|
||||
await _renameEmailLabel(label);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const del = e.target.closest('[data-label-delete]');
|
||||
if (del) {
|
||||
const label = (state._libLabels || []).find(l => l.slug === del.dataset.labelDelete);
|
||||
if (label) {
|
||||
await _deleteEmailLabel(label);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row = e.target.closest('[data-label-slug]');
|
||||
if (row) {
|
||||
close();
|
||||
_applyLabelFilterFromPill(row.dataset.labelSlug);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _renderAccountsStrip() {
|
||||
const strip = document.getElementById('email-lib-accounts');
|
||||
if (!strip) return;
|
||||
|
|
@ -3015,6 +3361,7 @@ function _renderAccountsStrip() {
|
|||
_publishActiveAccount();
|
||||
_resetEmailListForFreshLoad({ useCache: false });
|
||||
_renderAccountsStrip();
|
||||
await _loadLabels();
|
||||
_loadEmails({ force: true, useCache: false });
|
||||
_loadFolders({ resetMissing: true }).catch(() => {});
|
||||
_refreshAccountUnreadHighlights().catch(() => {});
|
||||
|
|
@ -3484,9 +3831,26 @@ const _LIB_FILTER_OPTIONS = [
|
|||
{ value: 'filter:tag:spam', label: 'Spam', keywords: ['spam', 'junk'] },
|
||||
];
|
||||
|
||||
function _labelFilterOptions() {
|
||||
return (state._libLabels || []).map(label => {
|
||||
const slug = _normalizeEmailLabelSlug(label?.slug || label?.name);
|
||||
const name = String(label?.name || slug).trim();
|
||||
return {
|
||||
value: `filter:label:${slug}`,
|
||||
label: name,
|
||||
keywords: [name.toLowerCase(), slug.replace(/-/g, ' '), 'label', `label ${name.toLowerCase()}`],
|
||||
};
|
||||
}).filter(opt => opt.value !== 'filter:label:' && opt.label);
|
||||
}
|
||||
|
||||
function _allLibFilterOptions() {
|
||||
return _LIB_FILTER_OPTIONS.concat(_labelFilterOptions());
|
||||
}
|
||||
|
||||
function _libFilterIconFor(value) {
|
||||
// value is 'filter:<X>' — strip prefix and reuse the existing icon map.
|
||||
const v = String(value || '').replace(/^filter:/, '');
|
||||
if (v.startsWith('label:')) return '<span class="email-filter-label-dot" aria-hidden="true"></span>';
|
||||
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>';
|
||||
return _EMAIL_FILTER_ICONS[v] || _EMAIL_FILTER_ICONS['all'];
|
||||
}
|
||||
|
|
@ -3507,7 +3871,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 = _allLibFilterOptions()
|
||||
.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 +4629,7 @@ const _EMAIL_FILTER_ICONS = {
|
|||
};
|
||||
|
||||
function _filterIcon(value) {
|
||||
if (String(value || '').startsWith('label:')) return '<span class="email-filter-label-dot" aria-hidden="true"></span>';
|
||||
return _EMAIL_FILTER_ICONS[value] || _EMAIL_FILTER_ICONS['all'];
|
||||
}
|
||||
|
||||
|
|
@ -4281,15 +4646,10 @@ function _renderFilterPickerCurrent() {
|
|||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
function _initFilterPicker() {
|
||||
function _renderFilterPickerMenu() {
|
||||
const sel = document.getElementById('email-lib-filter');
|
||||
const picker = document.getElementById('email-filter-picker');
|
||||
const btn = document.getElementById('email-filter-btn');
|
||||
const menu = document.getElementById('email-filter-menu');
|
||||
if (!sel || !picker || !btn || !menu || picker._wired) return;
|
||||
picker._wired = true;
|
||||
|
||||
// Build menu from the hidden <select> contents (preserves optgroup labels).
|
||||
if (!sel || !menu) return;
|
||||
const items = [];
|
||||
for (const child of sel.children) {
|
||||
if (child.tagName === 'OPTGROUP') {
|
||||
|
|
@ -4303,13 +4663,47 @@ function _initFilterPicker() {
|
|||
}
|
||||
menu.innerHTML = items.map(it => {
|
||||
if (!it.value) {
|
||||
return `<div class="email-filter-group">${it.group}</div>`;
|
||||
return `<div class="email-filter-group">${_esc(it.group)}</div>`;
|
||||
}
|
||||
return `<button type="button" role="option" class="email-filter-item" data-value="${it.value}">
|
||||
return `<button type="button" role="option" class="email-filter-item" data-value="${_esc(it.value)}">
|
||||
<span class="email-filter-item-icon">${_filterIcon(it.value)}</span>
|
||||
<span class="email-filter-item-label">${it.label}</span>
|
||||
<span class="email-filter-item-label">${_esc(it.label)}</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _syncLabelFilterOptions() {
|
||||
const sel = document.getElementById('email-lib-filter');
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll('optgroup[data-custom-label-group]').forEach(g => g.remove());
|
||||
const labels = state._libLabels || [];
|
||||
if (labels.length) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = 'Labels';
|
||||
group.dataset.customLabelGroup = '1';
|
||||
for (const label of labels) {
|
||||
const slug = _normalizeEmailLabelSlug(label?.slug || label?.name);
|
||||
if (!slug) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `label:${slug}`;
|
||||
opt.textContent = label.name || slug.replace(/-/g, ' ');
|
||||
group.appendChild(opt);
|
||||
}
|
||||
if (group.children.length) sel.appendChild(group);
|
||||
}
|
||||
_renderFilterPickerMenu();
|
||||
_renderFilterPickerCurrent();
|
||||
}
|
||||
|
||||
function _initFilterPicker() {
|
||||
const sel = document.getElementById('email-lib-filter');
|
||||
const picker = document.getElementById('email-filter-picker');
|
||||
const btn = document.getElementById('email-filter-btn');
|
||||
const menu = document.getElementById('email-filter-menu');
|
||||
if (!sel || !picker || !btn || !menu || picker._wired) return;
|
||||
picker._wired = true;
|
||||
|
||||
_renderFilterPickerMenu();
|
||||
|
||||
const close = () => {
|
||||
menu.hidden = true;
|
||||
|
|
@ -4913,27 +5307,8 @@ function _createCard(em) {
|
|||
titleRow.appendChild(att);
|
||||
}
|
||||
|
||||
const tags = state._libShowTags ? _visibleEmailTagsForRender(em) : [];
|
||||
if (state._libShowTags && (tags.length || em.is_spam_verdict)) {
|
||||
const tagWrap = document.createElement('span');
|
||||
tagWrap.className = 'email-tags email-card-tags' + (tags.length > 1 ? ' email-tags-collapsed' : '');
|
||||
tagWrap.innerHTML = _emailTagGroupHtml(tags, em);
|
||||
if (em.is_spam_verdict) {
|
||||
tagWrap.insertAdjacentHTML('beforeend', '<span class="email-tag email-tag-spam">spam</span>');
|
||||
}
|
||||
tagWrap.addEventListener('click', (ev) => {
|
||||
const calBtn = ev.target.closest('[data-calendar-event-uid]');
|
||||
const tagBtn = ev.target.closest('[data-email-filter-tag]');
|
||||
const moreBtn = ev.target.closest('[data-email-tags-more]');
|
||||
if (!calBtn && !tagBtn && !moreBtn) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (moreBtn) {
|
||||
const expanded = tagWrap.classList.toggle('email-tags-expanded');
|
||||
moreBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
} else if (calBtn) _openCalendarEventFromEmail(calBtn.dataset.calendarEventUid);
|
||||
else _applyTagFilterFromPill(tagBtn.dataset.emailFilterTag);
|
||||
});
|
||||
const tagWrap = _buildEmailCardTagWrap(em);
|
||||
if (tagWrap) {
|
||||
titleRow.appendChild(tagWrap);
|
||||
}
|
||||
|
||||
|
|
@ -4987,6 +5362,7 @@ function _createCard(em) {
|
|||
|
||||
if (em.is_flagged) {
|
||||
const star = document.createElement('span');
|
||||
star.className = 'email-card-favorite';
|
||||
star.title = 'Favorited';
|
||||
star.style.cssText = 'color:var(--accent, var(--red));opacity:0.85;flex-shrink:0;display:inline-flex;';
|
||||
star.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
||||
|
|
@ -7417,6 +7793,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _translateIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
|
||||
const _labelIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>';
|
||||
|
||||
const closeAndRemove = async () => {
|
||||
// Pick the next neighbour BEFORE we re-render so we know which email to
|
||||
|
|
@ -7464,6 +7841,11 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||
icon: _translateIcon,
|
||||
submenu: 'translate',
|
||||
},
|
||||
{
|
||||
label: 'Labels',
|
||||
icon: _labelIcon,
|
||||
submenu: 'labels',
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
|
||||
|
|
@ -7640,6 +8022,10 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||
_showEmailTranslateSubmenu(reader, dropdown);
|
||||
return;
|
||||
}
|
||||
if (a.submenu === 'labels') {
|
||||
_showEmailLabelSubmenu(em, dropdown, { preserveOpenReader: true, card, reader });
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
|
|
@ -7680,6 +8066,7 @@ function _showCardMenu(em, anchor) {
|
|||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _labelIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82z"/><circle cx="7.5" cy="7.5" r="1.5"/></svg>';
|
||||
|
||||
const isSentFolder = /sent/i.test(state._libFolder);
|
||||
|
||||
|
|
@ -7700,6 +8087,7 @@ function _showCardMenu(em, anchor) {
|
|||
await _openEmailAsTab(em, folder);
|
||||
}},
|
||||
{ label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
|
||||
{ label: 'Labels', icon: _labelIcon, submenu: 'labels' },
|
||||
];
|
||||
|
||||
if (!isSentFolder) {
|
||||
|
|
@ -7847,6 +8235,10 @@ function _showCardMenu(em, anchor) {
|
|||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
if (a.submenu === 'labels') {
|
||||
_showEmailLabelSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
|
|
@ -8333,6 +8725,54 @@ function _showEmailTranslateSubmenu(reader, parentDropdown) {
|
|||
}
|
||||
}
|
||||
|
||||
function _showEmailLabelSubmenu(em, parentDropdown, opts = {}) {
|
||||
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>Labels</span>';
|
||||
parentDropdown.appendChild(header);
|
||||
|
||||
const newItem = document.createElement('div');
|
||||
newItem.className = 'dropdown-item-compact';
|
||||
newItem.innerHTML = '<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg></span><span>New label</span>';
|
||||
newItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const label = await _createEmailLabel();
|
||||
if (label) await _toggleEmailLabel(em, label, true, opts);
|
||||
parentDropdown.remove();
|
||||
});
|
||||
parentDropdown.appendChild(newItem);
|
||||
|
||||
const labels = state._libLabels || [];
|
||||
if (!labels.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'email-label-empty';
|
||||
empty.textContent = 'No labels yet';
|
||||
parentDropdown.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dropdown-divider';
|
||||
parentDropdown.appendChild(sep);
|
||||
const assigned = new Set((Array.isArray(em?.labels) ? em.labels : []).map(l => l.slug));
|
||||
for (const label of labels) {
|
||||
const selected = assigned.has(label.slug);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dropdown-item-compact';
|
||||
item.innerHTML = `
|
||||
<span class="dropdown-icon">${selected ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6 9 17l-5-5"/></svg>' : '<span class="email-label-dot" style="--email-label-color:' + _esc(label.color || '#60a5fa') + '"></span>'}</span>
|
||||
<span>${_esc(label.name)}</span>
|
||||
`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
await _toggleEmailLabel(em, label, !selected, opts);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reminder submenu (used by both email menus) ----
|
||||
function _showLibRemindSubmenu(em, parentDropdown) {
|
||||
parentDropdown.innerHTML = '';
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const state = {
|
|||
_libFolders: [],
|
||||
_libAccountId: null, // null = backend default account
|
||||
_libAccounts: [], // list of accounts for the chip strip
|
||||
_libLabels: [],
|
||||
_libPendingExpandUid: null,
|
||||
_libSearch: '',
|
||||
_libFilter: 'all', // all, unread, unanswered
|
||||
|
|
|
|||
101
static/style.css
101
static/style.css
|
|
@ -32238,6 +32238,11 @@ body.doc-find-active mark.doc-find-mark.current {
|
|||
top: 0;
|
||||
}
|
||||
.email-tags-more:hover { opacity: 0.92; }
|
||||
.email-tags-expanded .email-tags-more {
|
||||
min-width: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
.email-tags-expanded .email-tags-more-count { display: none; }
|
||||
.email-tags-expanded .email-tags-more svg { transform: rotate(180deg); }
|
||||
.email-tags-toggle-inline {
|
||||
position: absolute;
|
||||
|
|
@ -32289,6 +32294,102 @@ body.doc-find-active mark.doc-find-mark.current {
|
|||
.email-tag-clickable:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
.email-label-pill {
|
||||
--email-label-color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--email-label-color) 18%, transparent);
|
||||
color: var(--email-label-color);
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.email-label-dot,
|
||||
.email-filter-label-dot {
|
||||
--email-label-color: var(--accent, var(--red));
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--email-label-color);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.email-filter-label-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
|
||||
}
|
||||
.email-labels-manage-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0 !important;
|
||||
color: var(--muted, var(--fg));
|
||||
}
|
||||
.email-labels-manage-btn:hover,
|
||||
.email-labels-manage-btn.active {
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
.email-label-manager-menu {
|
||||
max-width: min(280px, calc(100vw - 16px));
|
||||
}
|
||||
.email-label-manager-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.email-label-manager-filter {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.email-label-manager-filter span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.email-label-manager-icon {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.email-label-manager-filter:hover,
|
||||
.email-label-manager-icon:hover {
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
.email-label-manager-icon.danger:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.email-label-empty {
|
||||
padding: 8px 9px;
|
||||
font-size: 11px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.email-tag-work { background: rgba(96, 165, 250, 0.22); color: #60a5fa; }
|
||||
.email-tag-personal { background: rgba(74, 222, 128, 0.22); color: #4ade80; }
|
||||
.email-tag-finance { background: rgba(250, 204, 21, 0.22); color: #facc15; }
|
||||
|
|
|
|||
142
tests/test_email_local_labels.py
Normal file
142
tests/test_email_local_labels.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _route_endpoint(router, path: str, method: str):
|
||||
method = method.upper()
|
||||
for route in router.routes:
|
||||
if route.path == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def test_email_label_tables_are_created(tmp_path, monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
|
||||
db_path = tmp_path / "scheduled_emails.db"
|
||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||
|
||||
email_helpers._init_scheduled_db()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
defs = conn.execute("PRAGMA table_info(email_label_definitions)").fetchall()
|
||||
assigns = conn.execute("PRAGMA table_info(email_label_assignments)").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def_pk = [r[1] for r in sorted((r for r in defs if r[5]), key=lambda r: r[5])]
|
||||
assign_pk = [r[1] for r in sorted((r for r in assigns if r[5]), key=lambda r: r[5])]
|
||||
assert def_pk == ["owner", "account_id", "slug"]
|
||||
assert assign_pk == ["owner", "account_id", "message_key", "label_slug"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_label_routes_are_owner_and_account_scoped(tmp_path, monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
import routes.email_routes as email_routes
|
||||
|
||||
db_path = tmp_path / "scheduled_emails.db"
|
||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
||||
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
||||
monkeypatch.setattr(email_routes, "_assert_owns_account", lambda account_id, owner: None)
|
||||
email_helpers._init_scheduled_db()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
create_label = _route_endpoint(router, "/api/email/labels", "POST")
|
||||
list_labels = _route_endpoint(router, "/api/email/labels", "GET")
|
||||
add_message_label = _route_endpoint(router, "/api/email/labels/message", "POST")
|
||||
remove_message_label = _route_endpoint(router, "/api/email/labels/message/{slug}", "DELETE")
|
||||
|
||||
alice_label = await create_label(
|
||||
email_routes.EmailLabelCreateRequest(
|
||||
name="Client Work",
|
||||
color="#60a5fa",
|
||||
account_id="acct-a",
|
||||
),
|
||||
owner="alice",
|
||||
)
|
||||
await create_label(
|
||||
email_routes.EmailLabelCreateRequest(
|
||||
name="Client Work",
|
||||
color="#4ade80",
|
||||
account_id="acct-a",
|
||||
),
|
||||
owner="bob",
|
||||
)
|
||||
|
||||
assert alice_label["label"]["slug"] == "client-work"
|
||||
alice_labels = await list_labels(account_id="acct-a", owner="alice")
|
||||
bob_labels = await list_labels(account_id="acct-a", owner="bob")
|
||||
other_account_labels = await list_labels(account_id="acct-b", owner="alice")
|
||||
assert [l["name"] for l in alice_labels["labels"]] == ["Client Work"]
|
||||
assert [l["name"] for l in bob_labels["labels"]] == ["Client Work"]
|
||||
assert other_account_labels["labels"] == []
|
||||
|
||||
await add_message_label(
|
||||
email_routes.EmailLabelMessageRequest(
|
||||
label="client-work",
|
||||
uid="9",
|
||||
folder="INBOX",
|
||||
account_id="acct-a",
|
||||
message_id="<shared@example.com>",
|
||||
subject="Shared",
|
||||
sender="sender@example.com",
|
||||
),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
alice_emails = [{"uid": "9", "message_id": "<shared@example.com>"}]
|
||||
bob_emails = [{"uid": "9", "message_id": "<shared@example.com>"}]
|
||||
email_routes._attach_custom_email_labels("alice", "acct-a", "Archive", alice_emails)
|
||||
email_routes._attach_custom_email_labels("bob", "acct-a", "Archive", bob_emails)
|
||||
assert [l["slug"] for l in alice_emails[0]["labels"]] == ["client-work"]
|
||||
assert bob_emails[0]["labels"] == []
|
||||
|
||||
mids, uids = email_routes._email_label_filter_matches("alice", "acct-a", "INBOX", "client-work")
|
||||
assert mids == ["<shared@example.com>"]
|
||||
assert uids == []
|
||||
|
||||
removed = await remove_message_label(
|
||||
"client-work",
|
||||
uid="9",
|
||||
folder="INBOX",
|
||||
account_id="acct-a",
|
||||
message_id="<shared@example.com>",
|
||||
owner="alice",
|
||||
)
|
||||
assert removed["removed"] == 1
|
||||
alice_emails = [{"uid": "9", "message_id": "<shared@example.com>"}]
|
||||
email_routes._attach_custom_email_labels("alice", "acct-a", "INBOX", alice_emails)
|
||||
assert alice_emails[0]["labels"] == []
|
||||
|
||||
|
||||
def test_email_label_names_reject_reserved_tags():
|
||||
import routes.email_routes as email_routes
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
email_routes._email_label_slug_from_name("Urgent")
|
||||
|
||||
|
||||
def test_email_label_message_key_prefers_message_id():
|
||||
import routes.email_routes as email_routes
|
||||
|
||||
assert email_routes._email_label_message_key("INBOX", "9", "<m@example.com>") == "mid:<m@example.com>"
|
||||
assert email_routes._email_label_message_key("Archive", "9", "") == "uid:Archive:9"
|
||||
|
||||
|
||||
def test_email_library_exposes_local_label_controls():
|
||||
text = open("static/js/emailLibrary.js", encoding="utf-8").read()
|
||||
|
||||
assert "email-labels-manage-btn" in text
|
||||
assert "/api/email/labels/message" in text
|
||||
assert "filter:label:" in text
|
||||
assert "data-email-filter-label" in text
|
||||
assert "preserveOpenReader" in text
|
||||
assert "_refreshEmailLabelUi" in text
|
||||
assert "_refreshEmailCardTags" in text
|
||||
assert "_buildEmailCardTagWrap" in text
|
||||
assert "email-tags-more-count" in text
|
||||
assert "Collapse tags" in text
|
||||
Loading…
Add table
Reference in a new issue