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 => `${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 = {}) { + + + + + `).join(''); + dropdown.innerHTML = ` + + ${labels.length ? '' + rows : '
No labels yet
'} + `; + }; + 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:' — strip prefix and reuse the existing icon map. const v = String(value || '').replace(/^filter:/, ''); + if (v.startsWith('label:')) return ''; if (v === 'has-attachments') return ''; 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 ''; 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