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