Labels in Mail UI (chips + Actions + sidebar), plain-text Lato, calendar-panel polish

Labels:
- Moved labelling out of the "+label" message slot into Mail: label chips on the
  thread subject bar (removable ✕) and before list subjects, and a Labels submenu
  in the per-message Actions menu (multi-select checkboxes + "Create new…").
  Per-message storage kept; the reading pane writes a thread-union aggregate so the
  list (no message ids) can show chips.
- Sidebar LABELS section rewritten: aligned monocolor tag rows, collapsible
  (collapsed by default), inline "+" create popover, hover "−" delete with a confirm
  popover, and click-to-filter the mail list by label.
- Backend: batch message/thread label reads, thread aggregate, create + delete
  (delete unhooks from all mail), and GET /labels/{name}/threads. Core GET /threads
  gained an `ids` param to power the label view. Removed the plugin-pages list from
  the Mail sidebar.

Plain-text mail now renders in Lato (self-hosted at /fonts, @font-face'd into the
sandboxed message iframe) instead of the monospace <pre> default.

Calendar right-panel (embed mode): restored the "…" view-options menu, pinned the
toolbar + mini-calendar (only the agenda scrolls), plain "All calendars" button
(inline resets), and the event popover now closes on any outside click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-03 20:53:35 +02:00
co-authored by Claude Opus 4.8
parent 1d1a621ce2
commit 0a478a6b26
16 changed files with 574 additions and 75 deletions
+13 -1
View File
@@ -37,6 +37,14 @@ _INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "applicati
_DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* Self-hosted Lato so plain-text bodies use the app font (the sandboxed iframe
can't inherit the parent's web fonts). Served from the frontend's /fonts. */
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Regular.ttf") format("truetype");
font-weight:400;font-style:normal;font-display:swap}}
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Bold.ttf") format("truetype");
font-weight:700;font-style:normal;font-display:swap}}
@font-face{{font-family:"Lato";src:url("/fonts/Lato-Italic.ttf") format("truetype");
font-weight:400;font-style:italic;font-display:swap}}
html,body{{margin:0;padding:0}}
body{{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px;
line-height:1.5;color:#1a1a1a;background:#fff;padding:14px;word-break:break-word}}
@@ -45,6 +53,10 @@ _DOC_TEMPLATE = """<!doctype html><html><head><meta charset="utf-8">
blockquote{{margin:0 0 0 12px;padding-left:12px;border-left:3px solid #e0e0e0;color:#555}}
a{{color:#2563eb}}
pre{{white-space:pre-wrap;word-wrap:break-word}}
/* Plain-text emails: proportional Lato instead of the monospace <pre> default. */
.wm-plaintext{{white-space:pre-wrap;word-wrap:break-word;
font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
font-size:15px;line-height:1.55}}
</style></head><body>{body}</body></html>"""
@@ -112,7 +124,7 @@ async def message_html(
return HTMLResponse(content=_wrap(html_part.content))
text_part = next((p for p in parts if p.kind == "text"), None)
body = html_lib.escape(text_part.content) if text_part else ""
return HTMLResponse(content=_wrap(f"<pre>{body}</pre>"))
return HTMLResponse(content=_wrap(f'<div class="wm-plaintext">{body}</div>'))
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
+10 -1
View File
@@ -83,12 +83,21 @@ async def list_threads(
offset: int = Query(0, ge=0),
filter: str = Query("all"), # all | unread | pinned
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
ids: str = Query(""), # comma-separated thread ids (e.g. a plugin's label view)
) -> ThreadListOut:
# Collect WHERE conditions once so the page query and the total-count query
# stay in lockstep (offset pagination needs an accurate grand total).
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
if view.startswith("account:"):
if ids:
# Explicit id set (ignores view/folder) — used by plugin-driven views like
# "all mail with label X", which supply the matching thread ids.
try:
wanted = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
except ValueError:
raise_api_error(400, "bad_ids", "invalid thread id in ids")
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
elif view.startswith("account:"):
# A single account's inbox: threads with a message in THIS account's
# inbox mailbox. Keeps the per-account view consistent with the unified
# Inbox (archived/trashed/sent threads don't leak in).