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