From 7ea09fba20ca8f1e585437dcb72fb780a7035baa Mon Sep 17 00:00:00 2001
From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:17:34 +0200
Subject: [PATCH] feat(email): add local custom labels
---
routes/email_helpers.py | 45 +++
routes/email_routes.py | 479 ++++++++++++++++++++++++++++-
static/js/emailLibrary.js | 512 ++++++++++++++++++++++++++++---
static/js/emailLibrary/state.js | 1 +
static/style.css | 101 ++++++
tests/test_email_local_labels.py | 142 +++++++++
6 files changed, 1243 insertions(+), 37 deletions(-)
create mode 100644 tests/test_email_local_labels.py
diff --git a/routes/email_helpers.py b/routes/email_helpers.py
index c8639e1c7..2ef6608a5 100644
--- a/routes/email_helpers.py
+++ b/routes/email_helpers.py
@@ -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,
diff --git a/routes/email_routes.py b/routes/email_routes.py
index 3c8e407bd..2b5560d06 100644
--- a/routes/email_routes.py
+++ b/routes/email_routes.py
@@ -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")]
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index 6a0d3e294..de5fb4207 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -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 ``;
}
-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 ``;
+}
+
+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 => ``).join('');
- return `${visible[0]}${extra}`;
+ const moreCount = visible.length - 1;
+ return `${visible[0]}${extra}`;
+}
+
+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', 'spam');
+ }
+ _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 = {}) {
+