feat(email): add local custom labels

This commit is contained in:
Matyas Fenyves 2026-07-02 15:17:34 +02:00
parent 25c9e735ef
commit 7ea09fba20
6 changed files with 1243 additions and 37 deletions

View file

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

View file

@ -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")]

View file

@ -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 = '';

View file

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

View file

@ -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; }

View 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