Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI

Backend
- Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll
  (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX.
- Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr.
- Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge);
  inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID.
- Per-user storage quota (5 GiB default) and manual "sync all folders" action.
- Account editing (display/color/server/password) and per-account HTML signature.
- Account view scoped to that account's inbox; new migrations 0006–0009.

Frontend
- Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh.
- Aligned + widened mail-list margins; reading-pane bars fixed height.
- Rich-text composer: color palette, highlight, image; attachments; expand/pop-out.
- Signatures: auto-insert for new mail, replace on From change, reply insert.
- Restore/Delete actions per folder; drafts view merges local + remote, date-sorted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 15:27:09 +02:00
co-authored by Claude Opus 4.8
parent a5ec0d21cb
commit 3951df9755
39 changed files with 1917 additions and 139 deletions
+49 -12
View File
@@ -1,12 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { Icon } from "../Icon";
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor } from "./RichTextEditor";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
@@ -142,12 +142,26 @@ export function Composer({
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<RichTextEditorHandle>(null);
const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || "";
const fromEmail = account?.email ?? "";
const signature = account?.signature ?? "";
// When the From account changes, swap the signature block for the new one.
const firstAccount = useRef(true);
useEffect(() => {
if (firstAccount.current) {
firstAccount.current = false;
return;
}
editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId]);
// Fetch the original message's sanitized body to quote in a reply/forward.
useEffect(() => {
@@ -208,24 +222,24 @@ export function Composer({
const removeAttachment = (id: string) =>
setAttachments((prev) => prev.filter((a) => a.id !== id));
// The editor's seed content: an empty line to type into, then (for replies)
// the attribution line and the quoted original inside a blockquote.
const initialHtml = useMemo(() => {
// The editor's seed content. New messages get the account signature (2 blank
// lines above it); replies keep the quoted original in a marked block so the
// signature can later be inserted above it.
const initialHtml = (() => {
if (init.html) return init.html;
if (init.text) return textToHtml(init.text);
if (init.quote) {
return (
`<div><br></div>` +
`<div class="wmc-quoted">` +
`<div>${escapeHtml(init.quote.attribution)}</div>` +
`<blockquote>${quoteHtml}</blockquote>`
`<blockquote>${quoteHtml}</blockquote></div>`
);
}
return `<div><br></div>`;
// Only the quote resolving should (re)compute this; it mounts the editor once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quoteResolved]);
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
})();
const bodyReady = quoteResolved;
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
const payload = (): DraftInput => {
const html = bodyHtml || initialHtml;
@@ -269,12 +283,18 @@ export function Composer({
useEffect(
() => () => {
if (undoTimer.current) window.clearTimeout(undoTimer.current);
// On close, mirror the draft to the IMAP Drafts folder so it can be
// continued on any mail client (skip if it was sent or discarded).
if (draftId.current && !sentRef.current) {
void api.publishDraft(draftId.current).catch(() => {});
}
},
[],
);
const send = async () => {
setStatus("sending");
sentRef.current = true; // don't also mirror to Drafts on close
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
@@ -282,6 +302,7 @@ export function Composer({
setUndo({ outboxId: res.outbox_id });
undoTimer.current = window.setTimeout(onClose, 10000);
} catch (e) {
sentRef.current = false;
setStatus("error");
if (!(e instanceof ApiError)) throw e;
}
@@ -295,6 +316,7 @@ export function Composer({
/* too late */
}
if (undoTimer.current) window.clearTimeout(undoTimer.current);
sentRef.current = false; // back to a draft; mirror it on close
setUndo(null);
setStatus("");
};
@@ -403,6 +425,16 @@ export function Composer({
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
@@ -540,7 +572,12 @@ export function Composer({
{/* Body */}
{bodyReady ? (
<RichTextEditor initialHtml={initialHtml} onInput={setBodyHtml} showToolbar={showToolbar} />
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={setBodyHtml}
showToolbar={showToolbar}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
@@ -1,7 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useT } from "../../i18n";
// Imperative handle for the composer to manage the signature block.
export interface RichTextEditorHandle {
// Replace the current signature block's content (remove it if html is empty).
replaceSignature: (html: string) => void;
// Insert the signature below the body but above quoted content (reply menu).
insertSignature: (html: string) => void;
}
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input.
// Formatting uses document.execCommand — deprecated but universally supported and
@@ -125,17 +133,19 @@ const ICONS = {
),
} as const;
export function RichTextEditor({
initialHtml,
onInput,
onReady,
showToolbar = true,
}: {
initialHtml: string;
onInput: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
}) {
export const RichTextEditor = forwardRef<
RichTextEditorHandle,
{
initialHtml: string;
onInput: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
minHeight?: string; // Tailwind min-h-* class for the editable area
}
>(function RichTextEditor(
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" },
handleRef,
) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
const savedRange = useRef<Range | null>(null);
@@ -153,6 +163,39 @@ export function RichTextEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(handleRef, () => ({
replaceSignature: (html: string) => {
const el = ref.current;
if (!el) return;
const sig = el.querySelector(".wmc-signature");
if (html) {
if (sig) sig.innerHTML = html;
} else if (sig) {
sig.remove();
}
onInput(el.innerHTML);
},
insertSignature: (html: string) => {
const el = ref.current;
if (!el || !html) return;
el.querySelector(".wmc-signature")?.remove();
const spacerBefore = document.createElement("div");
spacerBefore.innerHTML = "<br>";
const block = document.createElement("div");
block.className = "wmc-signature";
block.innerHTML = html;
const spacerAfter = document.createElement("div");
spacerAfter.innerHTML = "<br>";
const quoted = el.querySelector(".wmc-quoted");
const anchor = quoted ?? null;
// ...content, blank line, signature, blank line, [quoted]
el.insertBefore(spacerBefore, anchor);
el.insertBefore(block, anchor);
el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML);
},
}));
// Remember the caret/selection so native pickers (color/file) that steal focus
// can restore where formatting should apply.
const saveSelection = useCallback(() => {
@@ -419,8 +462,8 @@ export function RichTextEditor({
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
className="wmc-editor min-h-[250px] flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none"
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
</div>
);
}
});
+85 -24
View File
@@ -1,5 +1,5 @@
import { useT } from "../../i18n";
import type { DraftOut } from "../../lib/types";
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string {
const d = new Date(iso);
@@ -9,41 +9,102 @@ function timeLabel(iso: string): string {
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
// Local drafts live in the `drafts` table (not an IMAP mailbox), so the Drafts
// folder lists them here rather than through the thread list.
function DraftRow({
recipients,
subject,
preview,
time,
badge,
onClick,
}: {
recipients: string;
subject: string;
preview: string;
time: string;
badge?: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="flex w-full flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left hover:bg-app-hover"
>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && (
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[10px] text-app-muted">
{badge}
</span>
)}
<span className="shrink-0 text-[11px] text-app-faint">{time}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">{subject}</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
}
// The Drafts folder lists local drafts (the `drafts` table) plus drafts found in
// the account's IMAP Drafts folder that were created on another client.
export function DraftsList({
drafts,
remoteDrafts,
onOpen,
onOpenRemote,
}: {
drafts: DraftOut[];
remoteDrafts: RemoteDraftOut[];
onOpen: (draft: DraftOut) => void;
onOpenRemote: (draft: RemoteDraftOut) => void;
}) {
const t = useT();
if (drafts.length === 0) {
if (drafts.length === 0 && remoteDrafts.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
}
// Merge local + remote drafts into one list sorted by date (newest first).
type Row = {
key: string;
date: string;
recipients: string;
subject: string;
preview: string;
badge?: string;
onClick: () => void;
};
const rows: Row[] = [
...drafts.map((d) => ({
key: `l-${d.id}`,
date: d.updated_at,
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
subject: d.payload.subject || t("compose.noSubject"),
preview: d.payload.text.trim(),
onClick: () => onOpen(d),
})),
...remoteDrafts.map((d) => ({
key: `r-${d.message_id}`,
date: d.updated_at,
recipients: d.to.join(", ") || t("compose.noRecipient"),
subject: d.subject || t("compose.noSubject"),
preview: d.snippet.trim(),
badge: t("compose.otherClient"),
onClick: () => onOpenRemote(d),
})),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return (
<div className="h-full overflow-y-auto" data-testid="drafts-list">
{drafts.map((draft) => {
const recipients = draft.payload.to.join(", ") || t("compose.noRecipient");
const preview = draft.payload.text.trim();
return (
<button
key={draft.id}
onClick={() => onOpen(draft)}
className="flex w-full flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left hover:bg-app-hover"
>
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(draft.updated_at)}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">
{draft.payload.subject || t("compose.noSubject")}
</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
})}
{rows.map((row) => (
<DraftRow
key={row.key}
recipients={row.recipients}
subject={row.subject}
preview={row.preview}
time={timeLabel(row.date)}
badge={row.badge}
onClick={row.onClick}
/>
))}
</div>
);
}
+3 -8
View File
@@ -61,18 +61,13 @@ export function MessageList({
height: item.size,
transform: `translateY(${item.start}px)`,
}}
className={`flex flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left ${
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
selected
? "bg-app-active"
: "hover:bg-app-hover"
}`}
>
<div className="flex items-center gap-2">
{unread ? (
<span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />
) : (
<span className="h-2 w-2 shrink-0" />
)}
<span className="flex shrink-0 gap-0.5">
{thread.account_ids.map((id) => (
<span
@@ -95,11 +90,11 @@ export function MessageList({
)}
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
</div>
<div className={`truncate pl-4 text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
{thread.subject}
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
</div>
<div className="truncate pl-4 text-xs text-app-faint">{thread.snippet}</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</button>
);
})}
@@ -120,6 +120,9 @@ function MessageItem({
{message.cc_addr.length > 0 && (
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
)}
{message.bcc_addr.length > 0 && (
<span className="text-app-faint"> · bcc: {formatList(message.bcc_addr)}</span>
)}
</span>
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
</span>
@@ -193,7 +196,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[44px] items-center justify-between border-b border-app-border bg-app-surface px-4">
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<button
className="shrink-0 text-xs text-app-accent hover:underline"
+14
View File
@@ -10,6 +10,7 @@ export const en = {
"common.saving": "Saving…",
"common.delete": "Delete",
"common.remove": "Remove",
"common.edit": "Edit",
"common.close": "Close",
"common.back": "Back",
"common.next": "Next",
@@ -45,6 +46,8 @@ export const en = {
"nav.spam": "Spam",
"nav.trash": "Trash",
"nav.search": "Search mail…",
"nav.storageQuota": "Storage used",
"nav.refresh": "Refresh all folders",
"mail.noConversations": "No conversations.",
"mail.selectConversation": "Select a conversation.",
@@ -57,6 +60,8 @@ export const en = {
"filter.pinned": "Pinned",
"filter.sortOldest": "Sort oldest first",
"filter.sortNewest": "Sort newest first",
"pager.prev": "Previous page",
"pager.next": "Next page",
"mail.reply": "Reply",
"mail.replyAll": "Reply all",
"mail.forward": "Forward",
@@ -93,6 +98,7 @@ export const en = {
"compose.savingDraft": "Saving draft…",
"compose.draftSaved": "Draft saved",
"compose.noDrafts": "No drafts.",
"compose.otherClient": "other client",
"compose.noRecipient": "(no recipient)",
"compose.noSubject": "(no subject)",
"compose.from": "From",
@@ -118,6 +124,7 @@ export const en = {
"compose.fmtColor": "Text color",
"compose.fmtHighlight": "Highlight color",
"compose.fmtImage": "Insert image",
"compose.insertSignature": "Insert signature",
"compose.linkPrompt": "Link URL:",
"compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
@@ -172,6 +179,13 @@ export const en = {
"settings.addAccount": "Add account",
"settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
"settings.editAccount": "Edit account",
"settings.color": "Color",
"settings.serverSettings": "Change server settings / password",
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
"settings.signature": "Signature",
"settings.configureAccount": "Configure account",
"settings.editSignature": "Edit signature",
"settings.theme": "Theme",
"theme.light": "Light",
"theme.dark": "Dark",
+21
View File
@@ -9,7 +9,9 @@ import type {
DraftInput,
DraftOut,
MailboxOut,
QuotaOut,
RecipientSuggestion,
RemoteDraftOut,
SearchOut,
SendResponse,
ServerConfig,
@@ -85,6 +87,7 @@ export const api = {
accounts: () => apiFetch<AccountOut[]>("/accounts"),
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
threads: (view = "unified", cursor?: string) => {
const params = new URLSearchParams({ view });
@@ -118,6 +121,18 @@ export const api = {
smtp: ServerConfig;
password: string;
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`),
updateAccount: (
id: string,
patch: {
display_name?: string;
color?: string;
signature?: string;
imap?: ServerConfig;
smtp?: ServerConfig;
password?: string;
},
) => apiFetch<AccountDetail>(`/accounts/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteAccount: (id: string) => apiFetch<void>(`/accounts/${id}`, { method: "DELETE" }),
// --- identity ---
@@ -140,6 +155,7 @@ export const api = {
settings: () => apiFetch<UserSettings>("/settings"),
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
// --- compose ---
suggestRecipients: (q: string) =>
@@ -149,6 +165,8 @@ export const api = {
updateDraft: (id: string, input: DraftInput) =>
apiFetch<DraftOut>(`/drafts/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteDraft: (id: string) => apiFetch<void>(`/drafts/${id}`, { method: "DELETE" }),
publishDraft: (id: string) =>
apiFetch<{ status: string }>(`/drafts/${id}/publish`, { method: "POST" }),
send: (draftId: string) =>
apiFetch<SendResponse>("/send", { method: "POST", body: JSON.stringify({ draft_id: draftId }) }),
undoSend: (outboxId: string) =>
@@ -179,6 +197,9 @@ export const api = {
deleteThreadPermanent: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) =>
apiFetch<DraftOut>(`/drafts/adopt/${messageId}`, { method: "POST" }),
// --- attachments ---
messageAttachments: (messageId: string) =>
+19
View File
@@ -48,6 +48,7 @@ export interface MessageEnvelope {
from_addr: Address[];
to_addr: Address[];
cc_addr: Address[];
bcc_addr: Address[];
date: string;
flags: string[];
is_unread: boolean;
@@ -71,6 +72,7 @@ export interface AccountOut {
display_name: string;
color: string;
state: string;
signature: string;
}
export interface MailboxOut {
@@ -143,6 +145,11 @@ export interface UserSettings {
allowed_image_senders: string[];
}
export interface QuotaOut {
used_bytes: number;
quota_bytes: number;
}
// --- M1: compose -------------------------------------------------------------
export interface RecipientSuggestion {
@@ -182,3 +189,15 @@ export interface SendResponse {
outbox_id: string;
undo_until: string;
}
export interface RemoteDraftOut {
message_id: string;
account_id: string;
imap_uid: number;
subject: string;
to: string[];
cc: string[];
bcc: string[];
snippet: string;
updated_at: string;
}
+2
View File
@@ -45,6 +45,8 @@ export function connectWs(queryClient: QueryClient): () => void {
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
if (typeof payload.thread_id === "string") {
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
}
+155 -7
View File
@@ -12,9 +12,20 @@ import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import type { ThreadSummary } from "../lib/types";
import type { QuotaOut, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
function quotaLabel(q: QuotaOut): string {
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
const pctStr = pct >= 10 ? String(Math.round(pct)) : String(Math.round(pct * 10) / 10);
const gb = q.quota_bytes / 1024 ** 3;
const gbStr = Number.isInteger(gb) ? String(gb) : gb.toFixed(1);
return `${pctStr}% of ${gbStr} GB`;
}
// Threads shown per page in the mail list.
const PAGE_SIZE = 50;
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
@@ -34,7 +45,11 @@ export function MailLayout() {
const [view, setView] = useState<string>("folder:inbox");
const [term, setTerm] = useState("");
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
const [listOrder, setListOrder] = useState<"newest" | "oldest">("newest");
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
);
const [page, setPage] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [composing, setComposing] = useState<ComposerInit | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
@@ -48,6 +63,7 @@ export function MailLayout() {
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
// Which special-use folder the reading pane is showing (drives the toolbar).
@@ -63,6 +79,11 @@ export function MailLayout() {
queryFn: api.listDrafts,
enabled: isDrafts,
});
const remoteDraftsQuery = useQuery({
queryKey: ["drafts-remote"],
queryFn: api.remoteDrafts,
enabled: isDrafts,
});
const searchQuery = useQuery({
queryKey: ["search", term],
queryFn: () => api.search(term),
@@ -70,6 +91,7 @@ export function MailLayout() {
});
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const allThreads: ThreadSummary[] = term.trim()
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
@@ -80,6 +102,16 @@ export function MailLayout() {
// The backend returns threads newest-first; reverse for an oldest-first view.
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
// Client-side pagination of the (filtered, ordered) list.
const total = threads.length;
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
const curPage = Math.min(page, maxPage);
const pageStart = curPage * PAGE_SIZE;
const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
const pageThreads = threads.slice(pageStart, pageEnd);
// Reset to the first page whenever the underlying list identity changes.
useEffect(() => setPage(0), [view, filter, term, listOrder]);
const threadsRef = useRef<ThreadSummary[]>(threads);
threadsRef.current = threads;
@@ -101,9 +133,27 @@ export function MailLayout() {
const afterMove = () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
navigate("/mail");
};
// Trigger a server-side sync of all folders; refresh the views once the worker
// has had a moment to poll (live WebSocket events also update them).
const doRefresh = async () => {
setRefreshing(true);
try {
await api.syncNow();
} catch {
/* ignore */
}
window.setTimeout(() => {
for (const key of ["threads", "drafts", "drafts-remote", "quota"]) {
queryClient.invalidateQueries({ queryKey: [key] });
}
setRefreshing(false);
}, 2500);
};
const archive = async () => {
if (!threadId) return;
await api.archiveThread(threadId);
@@ -230,6 +280,14 @@ export function MailLayout() {
>
{t("common.signOut")}
</button>
{quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
</div>
)}
</div>
</aside>
@@ -240,7 +298,7 @@ export function MailLayout() {
style={{ width: listW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
>
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-2.5">
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
@@ -250,7 +308,7 @@ export function MailLayout() {
/>
</div>
{!isDrafts && (
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border px-3 text-sm">
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
@@ -261,7 +319,7 @@ export function MailLayout() {
{t("filter.pinned")}
</FilterTab>
<button
className="ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="-mr-1.5 ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
@@ -274,13 +332,56 @@ export function MailLayout() {
{isDrafts && !term.trim() ? (
<DraftsList
drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []}
onOpen={(draft) => {
setComposing(draftToInit(draft));
setComposerExpanded(true);
}}
onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
setComposing(draftToInit(draft));
setComposerExpanded(true);
}}
/>
) : (
<MessageList threads={threads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
aria-label={t("nav.refresh")}
onClick={doRefresh}
disabled={refreshing}
>
<RefreshIcon spinning={refreshing} />
</button>
{!isDrafts && total > 0 ? (
<div className="flex items-center gap-3">
<span>
{pageStart + 1}–{pageEnd} of {total}
</span>
<div className="-mr-1 flex items-center gap-0.5">
<PagerButton
disabled={curPage === 0}
onClick={() => setPage(curPage - 1)}
label={t("pager.prev")}
dir="left"
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(curPage + 1)}
label={t("pager.next")}
dir="right"
/>
</div>
</div>
) : (
<span />
)}
</div>
</div>
@@ -291,7 +392,7 @@ export function MailLayout() {
<main className="relative min-w-0 flex-1 bg-app-surface">
{threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
@@ -369,6 +470,7 @@ export function MailLayout() {
setComposing(null);
setComposerExpanded(false);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
}}
/>
</div>
@@ -401,6 +503,52 @@ function FilterTab({
);
}
function PagerButton({
disabled,
onClick,
label,
dir,
}: {
disabled: boolean;
onClick: () => void;
label: string;
dir: "left" | "right";
}) {
return (
<button
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
</svg>
</button>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={spinning ? "animate-spin" : ""}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2v3h-3" />
</svg>
);
}
function SortIcon() {
return (
<svg
+289 -19
View File
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
@@ -53,7 +54,7 @@ function ProfileSection() {
const t = useT();
const { data } = useMe();
return (
<section className="max-w-md space-y-3">
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
@@ -114,7 +115,7 @@ function SecuritySection() {
});
return (
<section className="max-w-md space-y-6">
<section className="max-w-4xl space-y-6">
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
{enabled ? (
@@ -217,13 +218,21 @@ function AccountsSection() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>(
null,
);
const remove = useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
});
const close = () => {
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["accounts"] });
};
return (
<section className="max-w-lg space-y-3">
<section className="max-w-5xl space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
<button
@@ -235,20 +244,44 @@ function AccountsSection() {
</div>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(accounts.data ?? []).map((a) => (
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">{t("settings.syncState", { state: a.state })}</span>
</span>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
}}
>
{t("common.remove")}
</button>
<li key={a.id} className="px-3 py-2 text-sm">
{editing?.id === a.id && editing.mode === "account" ? (
<AccountEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : editing?.id === a.id && editing.mode === "signature" ? (
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
<span className="text-xs text-app-faint">
{t("settings.syncState", { state: a.state })}
</span>
</span>
<span className="flex items-center gap-3">
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "signature" })}
>
{t("settings.editSignature")}
</button>
<button
className="text-xs text-app-accent"
onClick={() => setEditing({ id: a.id, mode: "account" })}
>
{t("settings.configureAccount")}
</button>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) remove.mutate(a.id);
}}
>
{t("common.remove")}
</button>
</span>
</div>
)}
</li>
))}
</ul>
@@ -256,6 +289,243 @@ function AccountsSection() {
);
}
function AccountEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) });
const [displayName, setDisplayName] = useState("");
const [color, setColor] = useState("#888888");
const [showServer, setShowServer] = useState(false);
const [imapHost, setImapHost] = useState("");
const [imapPort, setImapPort] = useState("993");
const [imapTls, setImapTls] = useState("ssl");
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("465");
const [smtpTls, setSmtpTls] = useState("ssl");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
const d = detail.data;
if (!d) return;
setDisplayName(d.display_name);
setColor(d.color);
setImapHost(String(d.imap_config.host ?? ""));
setImapPort(String(d.imap_config.port ?? "993"));
setImapTls(String(d.imap_config.tls ?? "ssl"));
setSmtpHost(String(d.smtp_config.host ?? ""));
setSmtpPort(String(d.smtp_config.port ?? "465"));
setSmtpTls(String(d.smtp_config.tls ?? "ssl"));
}, [detail.data]);
const save = useMutation({
mutationFn: () => {
const patch: Parameters<typeof api.updateAccount>[1] = {
display_name: displayName,
color,
};
if (showServer) {
patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" };
patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" };
patch.password = password;
}
return api.updateAccount(accountId, patch);
},
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
const canSave = !save.isPending && (!showServer || password.length > 0);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
className="h-8 w-8 shrink-0 cursor-pointer rounded border border-app-border bg-transparent p-0.5"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
aria-label={t("settings.color")}
/>
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1"
placeholder={t("wizard.displayName")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
{!showServer ? (
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
{t("settings.serverSettings")}
</button>
) : (
<div className="space-y-2 rounded border border-app-border p-2">
<p className="text-xs text-app-faint">{t("settings.serverSettingsHint")}</p>
<ServerRow
label={t("wizard.imapServer")}
host={imapHost}
port={imapPort}
tls={imapTls}
onHost={setImapHost}
onPort={setImapPort}
onTls={setImapTls}
/>
<ServerRow
label={t("wizard.smtpServer")}
host={smtpHost}
port={smtpPort}
tls={smtpTls}
onHost={setSmtpHost}
onPort={setSmtpPort}
onTls={setSmtpTls}
/>
<input
className="w-full rounded border border-app-border bg-app-surface px-2 py-1"
type="password"
placeholder={t("wizard.accountPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={!canSave}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function SignatureEditor({
accountId,
onCancel,
onSaved,
}: {
accountId: string;
onCancel: () => void;
onSaved: () => void;
}) {
const t = useT();
const detail = useQuery({
queryKey: ["account", accountId],
queryFn: () => api.accountDetail(accountId),
});
const [signature, setSignature] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (detail.data) setSignature(detail.data.signature);
}, [detail.data]);
const save = useMutation({
mutationFn: () => api.updateAccount(accountId, { signature }),
onSuccess: onSaved,
onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"),
});
if (!detail.data) return <div className="py-1 text-app-faint">{t("common.loading")}</div>;
return (
<div className="space-y-2">
<div className="text-xs text-app-muted">
{t("settings.signature")} — {detail.data.email}
</div>
<div className="flex flex-col overflow-hidden rounded border border-app-border">
<RichTextEditor
initialHtml={detail.data.signature}
onInput={setSignature}
minHeight="min-h-[160px]"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex gap-2">
<button
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => {
setError("");
save.mutate();
}}
>
{save.isPending ? t("common.saving") : t("common.save")}
</button>
<button className="px-3 py-1 text-sm text-app-muted hover:text-app-fg" onClick={onCancel}>
{t("common.cancel")}
</button>
</div>
</div>
);
}
function ServerRow({
label,
host,
port,
tls,
onHost,
onPort,
onTls,
}: {
label: string;
host: string;
port: string;
tls: string;
onHost: (v: string) => void;
onPort: (v: string) => void;
onTls: (v: string) => void;
}) {
const t = useT();
return (
<div className="flex items-center gap-1.5">
<span className="w-24 shrink-0 text-xs text-app-muted">{label}</span>
<input
className="min-w-0 flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.host")}
value={host}
onChange={(e) => onHost(e.target.value)}
/>
<input
className="w-16 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.port")}
value={port}
onChange={(e) => onPort(e.target.value)}
/>
<select
className="rounded border border-app-border bg-app-surface px-1 py-1 text-sm"
value={tls}
onChange={(e) => onTls(e.target.value)}
aria-label={t("wizard.security")}
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
);
}
function AppearanceSection() {
const t = useT();
const queryClient = useQueryClient();
@@ -267,7 +537,7 @@ function AppearanceSection() {
});
return (
<section className="max-w-md space-y-3">
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.appearance")}</h2>
<div className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.theme")}</span>
+1
View File
@@ -17,6 +17,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
to_addr: [],
cc_addr: [],
bcc_addr: [],
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
flags: unread ? [] : ["\\Seen"],
is_unread: unread,