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"