{body}"""
@@ -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"
{body}
"))
+ return HTMLResponse(content=_wrap(f'
{body}
'))
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py
index 019979b..d4ddd38 100644
--- a/backend/app/api/threads.py
+++ b/backend/app/api/threads.py
@@ -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).
diff --git a/frontend/public/fonts/Lato-Bold.ttf b/frontend/public/fonts/Lato-Bold.ttf
new file mode 100644
index 0000000..016068b
Binary files /dev/null and b/frontend/public/fonts/Lato-Bold.ttf differ
diff --git a/frontend/public/fonts/Lato-Italic.ttf b/frontend/public/fonts/Lato-Italic.ttf
new file mode 100644
index 0000000..0d0f69e
Binary files /dev/null and b/frontend/public/fonts/Lato-Italic.ttf differ
diff --git a/frontend/public/fonts/Lato-Regular.ttf b/frontend/public/fonts/Lato-Regular.ttf
new file mode 100644
index 0000000..bb2e887
Binary files /dev/null and b/frontend/public/fonts/Lato-Regular.ttf differ
diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx
index 4adf0f5..4bd9490 100644
--- a/frontend/src/components/list/MessageList.tsx
+++ b/frontend/src/components/list/MessageList.tsx
@@ -1,10 +1,13 @@
+import { useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { useT } from "../../i18n";
+import { api } from "../../lib/api";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon";
+import { LabelChips } from "../mail/LabelChips";
function timeLabel(iso: string): string {
const d = new Date(iso);
@@ -86,6 +89,15 @@ export function MessageList({
// every row shows a checkbox (selection mode) until all are cleared.
const checkMode = checkedIds.size > 0;
+ // Aggregated labels per thread (written by the reading pane) — shown before the subject.
+ const threadIds = threads.map((th) => th.id);
+ const labelsQuery = useQuery({
+ queryKey: ["thread-labels", threadIds],
+ queryFn: () => api.threadLabelsBatch(threadIds),
+ enabled: threadIds.length > 0,
+ });
+ const labelMap = labelsQuery.data ?? {};
+
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
@@ -208,11 +220,12 @@ export function MessageList({
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
- {thread.subject}
+
+ {thread.subject}
{thread.snippet}
diff --git a/frontend/src/components/mail/LabelChips.tsx b/frontend/src/components/mail/LabelChips.tsx
new file mode 100644
index 0000000..1772d68
--- /dev/null
+++ b/frontend/src/components/mail/LabelChips.tsx
@@ -0,0 +1,39 @@
+// Small muted pills for a message/thread's labels, shown before the subject in the
+// list and after the subject in the reading pane. With `onRemove`, each pill gets a
+// clickable ✕ (used in the reading pane to drop a label from the thread).
+export function LabelChips({
+ labels,
+ onRemove,
+ className = "",
+}: {
+ labels: string[];
+ onRemove?: (label: string) => void;
+ className?: string;
+}) {
+ if (!labels.length) return null;
+ return (
+ <>
+ {labels.map((l) => (
+
+ {l}
+ {onRemove && (
+
+ )}
+
+ ))}
+ >
+ );
+}
diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx
index 24f15a7..db10788 100644
--- a/frontend/src/components/thread/ThreadView.tsx
+++ b/frontend/src/components/thread/ThreadView.tsx
@@ -1,4 +1,4 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
@@ -9,6 +9,7 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
+import { LabelChips } from "../mail/LabelChips";
import { MessageBody } from "./MessageBody";
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
@@ -146,14 +147,25 @@ function ActionsMenu({
onPrint,
onDelete,
replyDisabled = false,
+ allLabels,
+ messageLabels,
+ onToggleLabel,
+ onCreateLabel,
}: {
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
replyDisabled?: boolean;
+ allLabels: string[];
+ messageLabels: string[];
+ onToggleLabel: (label: string) => void;
+ onCreateLabel: (name: string) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
+ const [labelsOpen, setLabelsOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [newName, setNewName] = useState("");
const ref = useRef(null);
useEffect(() => {
if (!open) return;
@@ -170,6 +182,9 @@ function ActionsMenu({
window.removeEventListener("blur", onBlur);
};
}, [open]);
+ useEffect(() => {
+ if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); }
+ }, [open]);
const item = (label: string, onClick: () => void, disabled = false) => (