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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user