Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { ComposeWindow } from "./routes/ComposeWindow";
|
||||
import { Login } from "./routes/Login";
|
||||
import { MailLayout } from "./routes/MailLayout";
|
||||
import { Register } from "./routes/Register";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
import { AccountWizard } from "./routes/wizard/AccountWizard";
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { data, isLoading, isError } = useMe();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
return connectWs(queryClient);
|
||||
}, [data, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-gray-500">Loading…</div>;
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route
|
||||
path="/mail/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MailLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/compose/:draftId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ComposeWindow />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/accounts/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AccountWizard />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/:section?"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Settings />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/mail" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Back-compat shim: folder icons are now part of the generic Icon component.
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export function FolderIcon({ name }: { name: string }) {
|
||||
return <Icon name={name} size={18} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Inline SVG icons (user-provided SVGs in frontend/icons/). They draw with
|
||||
// `currentColor` but pin a fixed color via an inline `style`; we strip that and
|
||||
// let the size come from CSS so each glyph inherits the surrounding text color
|
||||
// and adapts to light/dark themes.
|
||||
import archive from "../../icons/archive.svg?raw";
|
||||
import compose from "../../icons/compose.svg?raw";
|
||||
import drafts from "../../icons/drafts-28-regular.svg?raw";
|
||||
import forward from "../../icons/forward.svg?raw";
|
||||
import inbox from "../../icons/inbox.svg?raw";
|
||||
import pin from "../../icons/pin.svg?raw";
|
||||
import replyAll from "../../icons/reply-all-line.svg?raw";
|
||||
import reply from "../../icons/reply.svg?raw";
|
||||
import sent from "../../icons/sent-02.svg?raw";
|
||||
import spam from "../../icons/spam.svg?raw";
|
||||
import trash from "../../icons/trash.svg?raw";
|
||||
|
||||
const RAW: Record<string, string> = {
|
||||
archive,
|
||||
compose,
|
||||
drafts,
|
||||
forward,
|
||||
inbox,
|
||||
pin,
|
||||
reply,
|
||||
"reply-all": replyAll,
|
||||
sent,
|
||||
spam,
|
||||
trash,
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof RAW;
|
||||
|
||||
// Drop the baked-in size/color so the glyph fills its box and inherits color.
|
||||
function clean(svg: string): string {
|
||||
return svg
|
||||
.replace(/\sstyle="[^"]*"/i, "")
|
||||
.replace(/\swidth="[^"]*"/i, "")
|
||||
.replace(/\sheight="[^"]*"/i, "");
|
||||
}
|
||||
|
||||
const CLEANED: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(RAW).map(([name, svg]) => [name, clean(svg)]),
|
||||
);
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
className = "",
|
||||
}: {
|
||||
name: IconName | string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-flex shrink-0 items-center justify-center [&>svg]:h-full [&>svg]:w-full ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{ __html: CLEANED[name] ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// A vertical drag handle for adjusting the width of the panel to its left.
|
||||
export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
let lastX = e.clientX;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
onDelta(ev.clientX - lastX);
|
||||
lastX = ev.clientX;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="group relative z-20 -mx-0.5 w-1 shrink-0 cursor-col-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent group-hover:bg-app-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useT } from "../i18n";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
// Quick light/dark flip (the full light/dark/system selector lives in Settings).
|
||||
export function ThemeToggle() {
|
||||
const t = useT();
|
||||
const { resolved, setTheme } = useTheme();
|
||||
return (
|
||||
<button
|
||||
className="rounded p-2 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={t("theme.toggle")}
|
||||
aria-label={t("theme.toggle")}
|
||||
onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{resolved === "dark" ? "☀" : "☾"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, 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";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
|
||||
|
||||
export interface ComposerInit {
|
||||
accountId: string;
|
||||
threadId?: string | null;
|
||||
replyToMessageId?: string | null;
|
||||
kind: string; // reply | reply_all | forward | new
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string; // initial body when reopening an existing draft
|
||||
draftId?: string; // when reopening an existing draft
|
||||
attachmentIds?: string[]; // compose-attachment ids when reopening a draft
|
||||
// A reply/forward quotes the original: this attribution line sits above the
|
||||
// quoted message body (fetched from the original message's sanitized HTML).
|
||||
quote?: { attribution: string; messageId: string };
|
||||
}
|
||||
|
||||
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
||||
// popped-out window).
|
||||
export function draftToInit(draft: DraftOut): ComposerInit {
|
||||
return {
|
||||
accountId: draft.account_id,
|
||||
threadId: draft.thread_id,
|
||||
replyToMessageId: draft.reply_to_message_id,
|
||||
kind: draft.kind,
|
||||
to: draft.payload.to.join(", "),
|
||||
cc: draft.payload.cc.join(", "),
|
||||
subject: draft.payload.subject,
|
||||
text: draft.payload.text,
|
||||
html: draft.payload.html || undefined,
|
||||
attachmentIds: draft.payload.attachment_ids,
|
||||
draftId: draft.id,
|
||||
};
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function htmlToText(html: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent ?? "";
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => `<div>${escapeHtml(line) || "<br>"}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// A single header icon-button (Attach / window controls / etc.).
|
||||
function HeaderButton({
|
||||
onClick,
|
||||
title,
|
||||
active = false,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
||||
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const caret = (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Composer({
|
||||
init,
|
||||
onClose,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
fullWindow = false,
|
||||
}: {
|
||||
init: ComposerInit;
|
||||
onClose: () => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
fullWindow?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
const accounts: AccountOut[] = accountsQuery.data ?? [];
|
||||
|
||||
const [accountId, setAccountId] = useState(init.accountId);
|
||||
const [to, setTo] = useState(init.to ?? "");
|
||||
const [cc, setCc] = useState(init.cc ?? "");
|
||||
const [bcc, setBcc] = useState("");
|
||||
const [showCc, setShowCc] = useState(Boolean(init.cc));
|
||||
const [subject, setSubject] = useState(init.subject ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
|
||||
const [quoteHtml, setQuoteHtml] = useState("");
|
||||
const [quoteResolved, setQuoteResolved] = useState(!init.quote);
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [fromOpen, setFromOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
const [attachError, setAttachError] = useState("");
|
||||
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 undoTimer = useRef<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
const fromName = account?.display_name || "";
|
||||
const fromEmail = account?.email ?? "";
|
||||
|
||||
// Fetch the original message's sanitized body to quote in a reply/forward.
|
||||
useEffect(() => {
|
||||
const quote = init.quote;
|
||||
if (!quote) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
|
||||
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
||||
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
|
||||
} catch {
|
||||
/* leave the quote empty on failure */
|
||||
} finally {
|
||||
if (!cancelled) setQuoteResolved(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [init.quote]);
|
||||
|
||||
// Resolve attachment metadata when reopening a draft that already has some.
|
||||
useEffect(() => {
|
||||
const ids = init.attachmentIds ?? [];
|
||||
if (ids.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const rows = await api.composeAttachments(ids);
|
||||
if (!cancelled) setAttachments(rows);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files: FileList) => {
|
||||
setAttachError("");
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
setAttachError(t("compose.attachTooLarge"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const att = await api.uploadComposeAttachment(file);
|
||||
setAttachments((prev) => [...prev, att]);
|
||||
} catch (e) {
|
||||
setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (init.html) return init.html;
|
||||
if (init.text) return textToHtml(init.text);
|
||||
if (init.quote) {
|
||||
return (
|
||||
`<div><br></div>` +
|
||||
`<div>${escapeHtml(init.quote.attribution)}</div>` +
|
||||
`<blockquote>${quoteHtml}</blockquote>`
|
||||
);
|
||||
}
|
||||
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]);
|
||||
|
||||
const bodyReady = quoteResolved;
|
||||
|
||||
const payload = (): DraftInput => {
|
||||
const html = bodyHtml || initialHtml;
|
||||
return {
|
||||
account_id: accountId,
|
||||
thread_id: init.threadId ?? null,
|
||||
reply_to_message_id: init.replyToMessageId ?? null,
|
||||
kind: init.kind,
|
||||
payload: {
|
||||
to: splitList(to),
|
||||
cc: splitList(cc),
|
||||
bcc: splitList(bcc),
|
||||
subject,
|
||||
html,
|
||||
text: htmlToText(html),
|
||||
attachment_ids: attachments.map((a) => a.id),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (undo) return;
|
||||
const handle = window.setTimeout(async () => {
|
||||
if (!to && !subject && !bodyHtml) return;
|
||||
setStatus("saving");
|
||||
try {
|
||||
if (draftId.current) {
|
||||
await api.updateDraft(draftId.current, payload());
|
||||
} else {
|
||||
draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
setStatus("saved");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, 2000);
|
||||
return () => window.clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const send = async () => {
|
||||
setStatus("sending");
|
||||
try {
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
const res = await api.send(draftId.current);
|
||||
setUndo({ outboxId: res.outbox_id });
|
||||
undoTimer.current = window.setTimeout(onClose, 10000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
if (!(e instanceof ApiError)) throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const doUndo = async () => {
|
||||
if (!undo) return;
|
||||
try {
|
||||
await api.undoSend(undo.outboxId);
|
||||
} catch {
|
||||
/* too late */
|
||||
}
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
setUndo(null);
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const discard = async () => {
|
||||
if (draftId.current) {
|
||||
try {
|
||||
await api.deleteDraft(draftId.current);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
draftId.current = null;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const popOut = async () => {
|
||||
// Persist first so the new window can load this exact draft.
|
||||
try {
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// A window a quarter of the screen area (half width × half height), centered.
|
||||
const w = Math.round(window.screen.width / 2);
|
||||
const h = Math.round(window.screen.height / 2);
|
||||
const left = Math.round((window.screen.width - w) / 2);
|
||||
const top = Math.round((window.screen.height - h) / 2);
|
||||
window.open(
|
||||
`/compose/${draftId.current}`,
|
||||
`compose-${draftId.current}`,
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (undo) {
|
||||
return (
|
||||
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
|
||||
<span>{t("compose.sent")}</span>
|
||||
<button className="font-medium text-app-accent" onClick={doUndo}>
|
||||
{t("compose.undo")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
fullWindow
|
||||
? "h-full"
|
||||
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
|
||||
}`}
|
||||
>
|
||||
{/* Header: Send · Attach · formatting toggle · More · window controls */}
|
||||
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={status === "sending" || (!to && !cc)}
|
||||
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 1.5L7.2 8.8" />
|
||||
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
|
||||
</svg>
|
||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
||||
</button>
|
||||
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||
</svg>
|
||||
{t("compose.attach")}
|
||||
</HeaderButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
onClick={() => setShowToolbar((v) => !v)}
|
||||
title={t("compose.fmtBold")}
|
||||
active={showToolbar}
|
||||
>
|
||||
<span className="text-sm font-semibold underline">A</span>
|
||||
</HeaderButton>
|
||||
<div className="relative">
|
||||
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
|
||||
{t("compose.more")}
|
||||
</HeaderButton>
|
||||
{moreOpen && (
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setShowCc(true);
|
||||
}}
|
||||
>
|
||||
{t("compose.cc")} / {t("compose.bcc")}
|
||||
</button>
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
void discard();
|
||||
}}
|
||||
>
|
||||
{t("compose.discard")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
|
||||
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
||||
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>}
|
||||
{!fullWindow && onToggleExpand && (
|
||||
<HeaderButton
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? t("compose.collapse") : t("compose.expand")}
|
||||
active={expanded}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
)}
|
||||
{!fullWindow && (
|
||||
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
)}
|
||||
<HeaderButton onClick={onClose} title={t("common.close")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* From */}
|
||||
<div className="relative flex items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
|
||||
>
|
||||
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
|
||||
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
||||
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
|
||||
<Icon name="reply" size={13} />
|
||||
{t("compose.replyLabel")}
|
||||
</div>
|
||||
{fromOpen && (
|
||||
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setAccountId(a.id);
|
||||
setFromOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
|
||||
<span className="text-app-muted">{a.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<RecipientChips
|
||||
label={t("compose.to")}
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus={init.kind === "new" || init.kind === "forward"}
|
||||
rightSlot={
|
||||
!showCc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-app-muted hover:text-app-fg"
|
||||
onClick={() => setShowCc(true)}
|
||||
>
|
||||
{t("compose.cc")} {t("compose.bcc")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{showCc && (
|
||||
<>
|
||||
<RecipientChips label={t("compose.cc")} value={cc} onChange={setCc} />
|
||||
<RecipientChips label={t("compose.bcc")} value={bcc} onChange={setBcc} />
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
|
||||
placeholder={t("compose.subject")}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || attachError) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
{attachments.map((att) => (
|
||||
<span
|
||||
key={att.id}
|
||||
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||
</svg>
|
||||
<span className="max-w-[12rem] truncate">{att.filename}</span>
|
||||
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${att.filename}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{bodyReady ? (
|
||||
<RichTextEditor initialHtml={initialHtml} onInput={setBodyHtml} showToolbar={showToolbar} />
|
||||
) : (
|
||||
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RecipientSuggestion } from "../../lib/types";
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseAddr(token: string): { name: string; email: string } {
|
||||
const m = token.match(/^(.*?)\s*<([^>]+)>$/);
|
||||
if (m) return { name: m[1].trim(), email: m[2].trim() };
|
||||
return { name: "", email: token };
|
||||
}
|
||||
|
||||
// A recipient field rendering committed addresses as removable chips with an
|
||||
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
|
||||
// string ("Name <email>" or bare email) so the draft payload is unchanged.
|
||||
export function RecipientChips({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rightSlot,
|
||||
autoFocus,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rightSlot?: React.ReactNode;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const chips = splitList(value);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const commit = (token: string) => {
|
||||
const tok = token.trim().replace(/,+$/, "").trim();
|
||||
if (!tok) return;
|
||||
onChange([...chips, tok].join(", "));
|
||||
setDraft("");
|
||||
setOpen(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.trim().length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const results = await api.suggestRecipients(draft.trim());
|
||||
if (!cancelled) {
|
||||
setSuggestions(results);
|
||||
setOpen(results.length > 0);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 150);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative flex items-start gap-2 border-b border-app-border px-3 py-1.5">
|
||||
<label className="mt-1.5 w-10 shrink-0 text-xs font-semibold text-app-fg">{label}</label>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1.5 py-0.5">
|
||||
{chips.map((chip, i) => {
|
||||
const { name, email } = parseAddr(chip);
|
||||
return (
|
||||
<span
|
||||
key={`${chip}-${i}`}
|
||||
className="flex items-center gap-1 rounded border border-app-border bg-app-hover px-2 py-0.5 text-xs"
|
||||
>
|
||||
{name && <span className="font-medium text-app-fg">{name}</span>}
|
||||
<span className="text-app-muted">{name ? `<${email}>` : email}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
className="ml-0.5 text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${email}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
className="min-w-[8rem] flex-1 bg-transparent py-1 text-sm text-app-fg outline-none placeholder:text-app-faint"
|
||||
value={draft}
|
||||
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
} else if (e.key === "Backspace" && draft === "" && chips.length > 0) {
|
||||
removeAt(chips.length - 1);
|
||||
}
|
||||
}}
|
||||
onBlur={() => draft.trim() && commit(draft)}
|
||||
onFocus={() => setOpen(suggestions.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
{rightSlot && <div className="mt-1 flex shrink-0 items-center gap-2">{rightSlot}</div>}
|
||||
{open && (
|
||||
<ul className="absolute left-12 top-full z-30 mt-1 max-h-56 w-80 overflow-y-auto rounded-lg border border-app-border bg-app-elevated shadow-xl">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.email}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
commit(s.name ? `${s.name} <${s.email}>` : s.email);
|
||||
}}
|
||||
>
|
||||
{s.name && <span className="text-sm text-app-fg">{s.name}</span>}
|
||||
<span className="text-xs text-app-muted">{s.email}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// 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
|
||||
// the pragmatic choice for a compose surface of this size.
|
||||
|
||||
type Cmd =
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strikeThrough"
|
||||
| "insertUnorderedList"
|
||||
| "insertOrderedList"
|
||||
| "justifyLeft"
|
||||
| "justifyCenter"
|
||||
| "justifyRight";
|
||||
|
||||
const STATE_CMDS: Cmd[] = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikeThrough",
|
||||
"insertUnorderedList",
|
||||
"insertOrderedList",
|
||||
"justifyLeft",
|
||||
"justifyCenter",
|
||||
"justifyRight",
|
||||
];
|
||||
|
||||
// A fixed swatch palette (10 columns × 8 rows, row-major): a grayscale top row,
|
||||
// then a saturated hue row followed by tints (lighter) and shades (darker).
|
||||
const PALETTE: string[] = [
|
||||
"#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff",
|
||||
"#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff",
|
||||
"#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc",
|
||||
"#dd7e6b", "#ea9999", "#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd",
|
||||
"#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0",
|
||||
"#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79",
|
||||
"#85200c", "#990000", "#b45f06", "#bf9000", "#38761d", "#134f5c", "#1155cc", "#0b5394", "#351c75", "#741b47",
|
||||
"#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130",
|
||||
];
|
||||
|
||||
function Svg({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
bulletList: (
|
||||
<Svg>
|
||||
<circle cx="3" cy="4" r="0.9" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="8" r="0.9" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="0.9" fill="currentColor" stroke="none" />
|
||||
<path d="M6 4h8M6 8h8M6 12h8" />
|
||||
</Svg>
|
||||
),
|
||||
numberList: (
|
||||
<Svg>
|
||||
<path d="M6 4h8M6 8h8M6 12h8" />
|
||||
<path d="M2 3v2.5M2 5.5h1" strokeWidth="1.2" />
|
||||
<path d="M1.6 10.5h1.4L1.6 12.5h1.6" strokeWidth="1.2" />
|
||||
</Svg>
|
||||
),
|
||||
quote: (
|
||||
<Svg>
|
||||
<path d="M3 6.5C3 5 4 4.5 5 4.5M3 6.5v3.5h3V6.5H4.6C4.6 5.6 5 5.3 5.4 5.2" />
|
||||
<path d="M9 6.5C9 5 10 4.5 11 4.5M9 6.5v3.5h3V6.5h-1.4C10.6 5.6 11 5.3 11.4 5.2" />
|
||||
</Svg>
|
||||
),
|
||||
link: (
|
||||
<Svg>
|
||||
<path d="M6.5 9.5a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-1 1" />
|
||||
<path d="M9.5 6.5a2.5 2.5 0 0 0-3.5 0l-2 2a2.5 2.5 0 0 0 3.5 3.5l1-1" />
|
||||
</Svg>
|
||||
),
|
||||
alignLeft: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M2 7h8M2 10h11M2 13h7" />
|
||||
</Svg>
|
||||
),
|
||||
alignCenter: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M4 7h8M3 10h10M4 13h8" />
|
||||
</Svg>
|
||||
),
|
||||
alignRight: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M6 7h8M3 10h11M7 13h7" />
|
||||
</Svg>
|
||||
),
|
||||
clear: (
|
||||
<Svg>
|
||||
<path d="M5 4h8M9 4l-2 9M4 13h5" />
|
||||
<path d="M11 11l3 3M14 11l-3 3" strokeWidth="1.3" />
|
||||
</Svg>
|
||||
),
|
||||
highlight: (
|
||||
<Svg>
|
||||
<path d="M4 11.5l4.5-4.5 3 3L7 14.5H4v-3z" />
|
||||
<path d="M8.5 7l2.5-2.5 3 3L11.5 10" />
|
||||
<path d="M3 16.5h7" strokeWidth="1.8" />
|
||||
</Svg>
|
||||
),
|
||||
image: (
|
||||
<Svg>
|
||||
<rect x="2.5" y="3.5" width="11" height="9" rx="1" />
|
||||
<circle cx="6" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
<path d="M3 11l3-2.5 2.5 2 2-1.5L13.5 11" />
|
||||
</Svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export function RichTextEditor({
|
||||
initialHtml,
|
||||
onInput,
|
||||
onReady,
|
||||
showToolbar = true,
|
||||
}: {
|
||||
initialHtml: string;
|
||||
onInput: (html: string) => void;
|
||||
onReady?: (el: HTMLDivElement) => void;
|
||||
showToolbar?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const savedRange = useRef<Range | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState<Record<string, boolean>>({});
|
||||
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
|
||||
|
||||
// Seed the editable region exactly once.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.innerHTML = initialHtml;
|
||||
onReady?.(el);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Remember the caret/selection so native pickers (color/file) that steal focus
|
||||
// can restore where formatting should apply.
|
||||
const saveSelection = useCallback(() => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0 && ref.current?.contains(sel.anchorNode)) {
|
||||
savedRange.current = sel.getRangeAt(0).cloneRange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && savedRange.current) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedRange.current);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshActive = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el || document.activeElement !== el) return;
|
||||
saveSelection();
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const cmd of STATE_CMDS) {
|
||||
try {
|
||||
next[cmd] = document.queryCommandState(cmd);
|
||||
} catch {
|
||||
next[cmd] = false;
|
||||
}
|
||||
}
|
||||
setActive(next);
|
||||
}, [saveSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("selectionchange", refreshActive);
|
||||
return () => document.removeEventListener("selectionchange", refreshActive);
|
||||
}, [refreshActive]);
|
||||
|
||||
// Open/close the swatch palette (snapshotting the selection to apply color to).
|
||||
const openColorMenu = (kind: "fore" | "hilite") => {
|
||||
saveSelection();
|
||||
setColorMenu((prev) => (prev === kind ? null : kind));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (paletteRef.current && !paletteRef.current.contains(e.target as Node)) {
|
||||
setColorMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [colorMenu]);
|
||||
|
||||
const emit = () => {
|
||||
if (ref.current) onInput(ref.current.innerHTML);
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
emit();
|
||||
refreshActive();
|
||||
};
|
||||
|
||||
const link = () => {
|
||||
const url = window.prompt(t("compose.linkPrompt"), "https://");
|
||||
if (url) exec("createLink", url);
|
||||
};
|
||||
|
||||
const applyColor = (kind: "fore" | "hilite", color: string) => {
|
||||
ref.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("styleWithCSS", false, "true");
|
||||
if (kind === "fore") {
|
||||
document.execCommand("foreColor", false, color);
|
||||
} else if (!document.execCommand("hiliteColor", false, color)) {
|
||||
document.execCommand("backColor", false, color);
|
||||
}
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
emit();
|
||||
};
|
||||
|
||||
const insertImage = (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
window.alert(t("compose.imageTooLarge"));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
ref.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("insertImage", false, String(reader.result));
|
||||
emit();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const Btn = ({
|
||||
onClick,
|
||||
title,
|
||||
isActive = false,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded ${
|
||||
isActive ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const Sep = () => <span className="mx-1 h-5 w-px bg-app-border" />;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className={`flex-wrap items-center gap-0.5 border-b border-app-border px-2 py-1.5 ${
|
||||
showToolbar ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<Btn onClick={() => exec("bold")} title={t("compose.fmtBold")} isActive={active.bold}>
|
||||
<span className="text-sm font-bold">B</span>
|
||||
</Btn>
|
||||
<Btn onClick={() => exec("italic")} title={t("compose.fmtItalic")} isActive={active.italic}>
|
||||
<span className="font-serif text-sm italic">I</span>
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("underline")}
|
||||
title={t("compose.fmtUnderline")}
|
||||
isActive={active.underline}
|
||||
>
|
||||
<span className="text-sm underline">U</span>
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("strikeThrough")}
|
||||
title={t("compose.fmtStrike")}
|
||||
isActive={active.strikeThrough}
|
||||
>
|
||||
<span className="text-sm line-through">S</span>
|
||||
</Btn>
|
||||
<Sep />
|
||||
<Btn
|
||||
onClick={() => exec("insertUnorderedList")}
|
||||
title={t("compose.fmtBulletList")}
|
||||
isActive={active.insertUnorderedList}
|
||||
>
|
||||
{ICONS.bulletList}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("insertOrderedList")}
|
||||
title={t("compose.fmtNumberList")}
|
||||
isActive={active.insertOrderedList}
|
||||
>
|
||||
{ICONS.numberList}
|
||||
</Btn>
|
||||
<Btn onClick={() => exec("formatBlock", "blockquote")} title={t("compose.fmtQuote")}>
|
||||
{ICONS.quote}
|
||||
</Btn>
|
||||
<Btn onClick={link} title={t("compose.fmtLink")}>
|
||||
{ICONS.link}
|
||||
</Btn>
|
||||
<Sep />
|
||||
<div ref={paletteRef} className="relative flex items-center gap-0.5">
|
||||
<Btn onClick={() => openColorMenu("fore")} title={t("compose.fmtColor")}>
|
||||
<span className="relative flex h-4 w-4 items-center justify-center text-sm font-semibold leading-none">
|
||||
A
|
||||
<span
|
||||
className="absolute -bottom-0.5 left-0 h-[3px] w-full rounded"
|
||||
style={{ background: "#e5484d" }}
|
||||
/>
|
||||
</span>
|
||||
</Btn>
|
||||
<Btn onClick={() => openColorMenu("hilite")} title={t("compose.fmtHighlight")}>
|
||||
{ICONS.highlight}
|
||||
</Btn>
|
||||
{colorMenu && (
|
||||
<div className="absolute left-0 top-full z-40 mt-1 rounded-lg border border-app-border bg-app-elevated p-2 shadow-xl">
|
||||
<div className="grid grid-cols-[repeat(10,1rem)] gap-1">
|
||||
{PALETTE.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
aria-label={color}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
applyColor(colorMenu, color);
|
||||
setColorMenu(null);
|
||||
}}
|
||||
className="h-4 w-4 rounded-sm border border-black/10 hover:ring-2 hover:ring-app-accent"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Btn
|
||||
onClick={() => {
|
||||
saveSelection();
|
||||
imageInputRef.current?.click();
|
||||
}}
|
||||
title={t("compose.fmtImage")}
|
||||
>
|
||||
{ICONS.image}
|
||||
</Btn>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) insertImage(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Sep />
|
||||
<Btn
|
||||
onClick={() => exec("justifyLeft")}
|
||||
title={t("compose.fmtAlignLeft")}
|
||||
isActive={active.justifyLeft}
|
||||
>
|
||||
{ICONS.alignLeft}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("justifyCenter")}
|
||||
title={t("compose.fmtAlignCenter")}
|
||||
isActive={active.justifyCenter}
|
||||
>
|
||||
{ICONS.alignCenter}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("justifyRight")}
|
||||
title={t("compose.fmtAlignRight")}
|
||||
isActive={active.justifyRight}
|
||||
>
|
||||
{ICONS.alignRight}
|
||||
</Btn>
|
||||
<Sep />
|
||||
<Btn onClick={() => exec("removeFormat")} title={t("compose.fmtClear")}>
|
||||
{ICONS.clear}
|
||||
</Btn>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useT } from "../../i18n";
|
||||
import type { DraftOut } from "../../lib/types";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
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.
|
||||
export function DraftsList({
|
||||
drafts,
|
||||
onOpen,
|
||||
}: {
|
||||
drafts: DraftOut[];
|
||||
onOpen: (draft: DraftOut) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
if (drafts.length === 0) {
|
||||
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { AccountOut, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
||||
export function MessageList({
|
||||
threads,
|
||||
accounts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
threads: ThreadSummary[];
|
||||
accounts: AccountOut[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888";
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threads.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 82,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
if (threads.length === 0) {
|
||||
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full overflow-y-auto" data-testid="message-list">
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative", width: "100%" }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const thread = threads[item.index];
|
||||
const unread = thread.unread_count > 0;
|
||||
const selected = selectedId === thread.id;
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
onClick={() => onSelect(thread.id)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
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 ${
|
||||
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
|
||||
key={id}
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: colorFor(id) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
|
||||
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
|
||||
</span>
|
||||
{thread.message_count > 1 && (
|
||||
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
|
||||
{thread.message_count}
|
||||
</span>
|
||||
)}
|
||||
{thread.pinned && (
|
||||
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
|
||||
)}
|
||||
<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"}`}>
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AttachmentOut } from "../../lib/types";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FileGlyph() {
|
||||
return (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 2.5h7L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6 2.5z"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<path d="M8 12.5h8M8 15.5h8M8 18.5h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadGlyph() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Attachments({ messageId }: { messageId: string }) {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["attachments", messageId],
|
||||
queryFn: () => api.messageAttachments(messageId),
|
||||
});
|
||||
|
||||
const attachments = data ?? [];
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectedIds = [...selected];
|
||||
|
||||
return (
|
||||
<div className="border-t border-app-border px-3 py-3">
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={api.attachmentsZipUrl(messageId, selectedIds)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
{t("mail.downloadZip")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((att: AttachmentOut) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="flex min-w-[13rem] max-w-[18rem] items-center gap-2 rounded-md border border-app-border bg-app-surface px-2.5 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(att.id)}
|
||||
onChange={() => toggle(att.id)}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
||||
aria-label={att.filename}
|
||||
/>
|
||||
<span className="shrink-0 text-app-accent">
|
||||
<FileGlyph />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}>
|
||||
{att.filename}
|
||||
</span>
|
||||
<span className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
|
||||
</span>
|
||||
<a
|
||||
href={api.attachmentDownloadUrl(messageId, att.id)}
|
||||
title={t("mail.download")}
|
||||
aria-label={`${t("mail.download")} ${att.filename}`}
|
||||
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={api.attachmentsZipUrl(messageId)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-app-hover px-3 py-1.5 text-xs text-app-fg hover:bg-app-active"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
{t("mail.downloadAll", { count: attachments.length })}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Composer anchor slot (SPEC §9.4). M0: a reply placeholder attached to the
|
||||
// newest message. The single placement rule ("composer is always attached to the
|
||||
// newest message") lives in ThreadView; this is the slot it renders.
|
||||
|
||||
export function Composer({ subject }: { subject: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="composer-anchor"
|
||||
className="my-2 rounded border border-dashed border-gray-300 bg-gray-50 p-3 text-sm text-gray-500"
|
||||
>
|
||||
Reply to “{subject}” — composer (M1)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { MessageEnvelope } from "../../lib/types";
|
||||
import { Attachments } from "./Attachments";
|
||||
|
||||
// Sanitized message HTML in a sandboxed iframe (SPEC §9.4, I-8). We use
|
||||
// sandbox="allow-same-origin" WITHOUT allow-scripts: scripts still never run
|
||||
// (the content is nh3-sanitized and script-less), but same-origin lets us (a)
|
||||
// measure the content to auto-size the frame — so the message shows in full and
|
||||
// only the thread panel scrolls — and (b) send the session cookie so inline
|
||||
// (cid:) images authenticate and load.
|
||||
export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const [height, setHeight] = useState(80);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
queryFn: async () => {
|
||||
const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" });
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
},
|
||||
});
|
||||
|
||||
const resize = useCallback(() => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight);
|
||||
}, []);
|
||||
|
||||
const onLoad = () => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
resize();
|
||||
// Re-measure as images finish loading (they change the content height).
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
if (!img.complete) img.addEventListener("load", resize, { once: true });
|
||||
});
|
||||
observerRef.current?.disconnect();
|
||||
const ro = new ResizeObserver(resize);
|
||||
if (doc.body) ro.observe(doc.body);
|
||||
observerRef.current = ro;
|
||||
};
|
||||
|
||||
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||||
|
||||
const loadImages = async (persist: boolean) => {
|
||||
if (persist) {
|
||||
await api.allowRemoteImages(message.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["message-html", message.id] });
|
||||
}
|
||||
setAllowed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{message.has_remote_images && !allowed && (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
<span>{t("mail.imagesBlocked")}</span>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(false)}>
|
||||
{t("mail.loadImages")}
|
||||
</button>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(true)}>
|
||||
{t("mail.alwaysFromSender")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
|
||||
{isError && <div className="p-3 text-sm text-red-500">{t("mail.loadFailed")}</div>}
|
||||
{data !== undefined && !isLoading && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={`message-${message.id}`}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={data}
|
||||
onLoad={onLoad}
|
||||
style={{ height }}
|
||||
className="w-full border-0 bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.has_attachments && <Attachments messageId={message.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { MessageEnvelope } from "../../lib/types";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
import { Icon } from "../Icon";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
|
||||
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
||||
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
|
||||
// visual reversal below. The composer anchor is attached to the newest message;
|
||||
// any expanded message can additionally start a reply of its own.
|
||||
export interface ThreadViewProps {
|
||||
subject: string;
|
||||
messages: MessageEnvelope[];
|
||||
order: "asc" | "desc";
|
||||
replyKindSignal?: { kind: string; seq: number };
|
||||
}
|
||||
|
||||
function formatAddress(a: { name: string; email: string } | undefined): string {
|
||||
if (!a || !a.email) return "(unknown)";
|
||||
return a.name ? `${a.name} (${a.email})` : a.email;
|
||||
}
|
||||
|
||||
function formatList(addrs: { name: string; email: string }[]): string {
|
||||
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
|
||||
}
|
||||
|
||||
// Attribution line prefixed to a quoted original, e.g.
|
||||
// "On Fri, Jul 17, 2026, at 2:04 PM, Lukas Remis wrote:".
|
||||
function attributionFor(msg: MessageEnvelope): string {
|
||||
const d = new Date(msg.date);
|
||||
const date = d.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
|
||||
return `On ${date}, at ${time}, ${who} wrote:`;
|
||||
}
|
||||
|
||||
function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): ComposerInit {
|
||||
const from = msg.from_addr[0]?.email ?? "";
|
||||
const toEmails = msg.to_addr.map((a) => a.email);
|
||||
const norm = subject.replace(/^\s*(re|fwd?):\s*/i, "");
|
||||
const quote = { attribution: attributionFor(msg), messageId: msg.id };
|
||||
if (kind === "forward") {
|
||||
return {
|
||||
accountId: msg.account_id,
|
||||
threadId: msg.thread_id,
|
||||
replyToMessageId: msg.id,
|
||||
kind,
|
||||
subject: `Fwd: ${norm}`,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
|
||||
const cc = kind === "reply_all" ? msg.cc_addr.map((a) => a.email).join(", ") : "";
|
||||
return {
|
||||
accountId: msg.account_id,
|
||||
threadId: msg.thread_id,
|
||||
replyToMessageId: msg.id,
|
||||
kind,
|
||||
to,
|
||||
cc,
|
||||
subject: `Re: ${norm}`,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
function initials(msg: MessageEnvelope): string {
|
||||
const name = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?";
|
||||
return name.trim().slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function MessageItem({
|
||||
message,
|
||||
expanded,
|
||||
showReply,
|
||||
onToggle,
|
||||
onReply,
|
||||
innerRef,
|
||||
}: {
|
||||
message: MessageEnvelope;
|
||||
expanded: boolean;
|
||||
showReply: boolean;
|
||||
onToggle: () => void;
|
||||
onReply: (kind: string) => void;
|
||||
innerRef?: (el: HTMLDivElement | null) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md border border-app-border px-2.5 py-1 text-xs text-app-fg hover:bg-app-hover"
|
||||
onClick={() => onReply(kind)}
|
||||
>
|
||||
<Icon name={icon} size={14} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="message"
|
||||
data-message-id={message.id}
|
||||
ref={innerRef}
|
||||
className="overflow-hidden rounded-lg border border-app-border bg-app-surface"
|
||||
>
|
||||
<button className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-app-hover" onClick={onToggle}>
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-app-active text-sm font-medium text-app-fg">
|
||||
{initials(message)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
{message.is_unread && <span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium text-app-fg">{formatAddress(message.from_addr[0])}</span>
|
||||
<span className="text-app-faint"> → {formatList(message.to_addr)}</span>
|
||||
{message.cc_addr.length > 0 && (
|
||||
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="block truncate text-[13px] text-app-muted">{message.snippet}</span>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-app-border">
|
||||
<MessageBody message={message} />
|
||||
{showReply && (
|
||||
<div className="flex justify-end gap-2 border-t border-app-border px-3 py-2">
|
||||
{replyBtn("reply", t("mail.reply"), "reply")}
|
||||
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
||||
{replyBtn("forward", t("mail.forward"), "forward")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadView({ subject, messages, order, replyKindSignal }: ThreadViewProps) {
|
||||
const t = useT();
|
||||
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
|
||||
const defaultExpanded = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) if (m.is_unread) ids.add(m.id);
|
||||
if (newest) ids.add(newest.id);
|
||||
return ids;
|
||||
}, [messages, newest]);
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
|
||||
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
|
||||
|
||||
// The reply currently open: which message it targets, and its prefilled draft.
|
||||
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
|
||||
|
||||
const openReply = (msg: MessageEnvelope, kind: string) => {
|
||||
setExpanded((prev) => new Set(prev).add(msg.id));
|
||||
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [replyKindSignal?.seq]);
|
||||
|
||||
const newestRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
newestRef.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [newest?.id]);
|
||||
|
||||
const visual = order === "asc" ? messages : [...messages].reverse();
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const composerFor = (id: string) =>
|
||||
composing?.anchorId === id ? (
|
||||
<div className="py-2">
|
||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
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">
|
||||
<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"
|
||||
onClick={() => setExpanded(new Set(messages.map((m) => m.id)))}
|
||||
>
|
||||
{t("mail.expandAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 p-3">
|
||||
{visual.map((message) => {
|
||||
const isNewest = Boolean(newest && message.id === newest.id);
|
||||
const isComposing = composing?.anchorId === message.id;
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<MessageItem
|
||||
message={message}
|
||||
expanded={expanded.has(message.id)}
|
||||
showReply={expanded.has(message.id) && !isComposing}
|
||||
onToggle={() => toggle(message.id)}
|
||||
onReply={(kind) => openReply(message, kind)}
|
||||
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
||||
/>
|
||||
{isNewest ? (
|
||||
<div data-testid="composer-anchor">{composerFor(message.id)}</div>
|
||||
) : (
|
||||
composerFor(message.id)
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// English message catalog. Adding a locale = drop a sibling file with the same
|
||||
// keys and register it in i18n/index.tsx. Keys are flat, dotted namespaces.
|
||||
// Interpolate with {name} placeholders.
|
||||
|
||||
export const en = {
|
||||
"common.appName": "Webmail",
|
||||
"common.loading": "Loading…",
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving…",
|
||||
"common.delete": "Delete",
|
||||
"common.remove": "Remove",
|
||||
"common.close": "Close",
|
||||
"common.back": "Back",
|
||||
"common.next": "Next",
|
||||
"common.signOut": "Sign out",
|
||||
"common.retry": "Retry",
|
||||
"common.optional": "optional",
|
||||
|
||||
"auth.signInTitle": "Sign in",
|
||||
"auth.registerTitle": "Create account",
|
||||
"auth.handle": "Handle",
|
||||
"auth.handlePlaceholder": "e.g. alice",
|
||||
"auth.handleHint": "3–30 chars: a–z, 0–9, . _ -",
|
||||
"auth.password": "Password",
|
||||
"auth.passwordHint": "at least 8 characters",
|
||||
"auth.totpCode": "2FA code",
|
||||
"auth.totpPrompt": "Enter the 6-digit code from your authenticator app",
|
||||
"auth.signIn": "Sign in",
|
||||
"auth.signingIn": "Signing in…",
|
||||
"auth.create": "Create account",
|
||||
"auth.creating": "Creating…",
|
||||
"auth.noAccount": "No account?",
|
||||
"auth.haveAccount": "Already have an account?",
|
||||
"auth.register": "Register",
|
||||
|
||||
"nav.mail": "Mail",
|
||||
"nav.settings": "Settings",
|
||||
"nav.compose": "Compose",
|
||||
"nav.allAccounts": "All accounts",
|
||||
"nav.inbox": "Inbox",
|
||||
"nav.archive": "Archive",
|
||||
"nav.drafts": "Drafts",
|
||||
"nav.sent": "Sent",
|
||||
"nav.spam": "Spam",
|
||||
"nav.trash": "Trash",
|
||||
"nav.search": "Search mail…",
|
||||
|
||||
"mail.noConversations": "No conversations.",
|
||||
"mail.selectConversation": "Select a conversation.",
|
||||
"mail.loadingThread": "Loading thread…",
|
||||
"mail.expandAll": "Expand all",
|
||||
"mail.orderOldest": "Oldest first",
|
||||
"mail.orderNewest": "Newest first",
|
||||
"filter.all": "All mail",
|
||||
"filter.unread": "Unread",
|
||||
"filter.pinned": "Pinned",
|
||||
"filter.sortOldest": "Sort oldest first",
|
||||
"filter.sortNewest": "Sort newest first",
|
||||
"mail.reply": "Reply",
|
||||
"mail.replyAll": "Reply all",
|
||||
"mail.forward": "Forward",
|
||||
"mail.archive": "Archive",
|
||||
"mail.markRead": "Mark read",
|
||||
"mail.markUnread": "Mark unread",
|
||||
"mail.star": "Star",
|
||||
"mail.unstar": "Unstar",
|
||||
"mail.pin": "Pin",
|
||||
"mail.unpin": "Unpin",
|
||||
"mail.delete": "Delete",
|
||||
"mail.restore": "Restore",
|
||||
"mail.loadFailed": "Couldn't load this message.",
|
||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||
"mail.loadImages": "Load images",
|
||||
"mail.alwaysFromSender": "Always from this sender",
|
||||
"mail.to": "to",
|
||||
"mail.messageCount": "{count} messages",
|
||||
"mail.downloadAll": "Download all {count} attachments",
|
||||
"mail.downloadZip": "Download zip",
|
||||
"mail.download": "Download",
|
||||
|
||||
"compose.newMessage": "New message",
|
||||
"compose.to": "To",
|
||||
"compose.cc": "Cc",
|
||||
"compose.bcc": "Bcc",
|
||||
"compose.subject": "Subject",
|
||||
"compose.send": "Send",
|
||||
"compose.sending": "Sending…",
|
||||
"compose.discard": "Discard",
|
||||
"compose.sent": "Message sent.",
|
||||
"compose.undo": "Undo",
|
||||
"compose.sendFailed": "Send failed.",
|
||||
"compose.savingDraft": "Saving draft…",
|
||||
"compose.draftSaved": "Draft saved",
|
||||
"compose.noDrafts": "No drafts.",
|
||||
"compose.noRecipient": "(no recipient)",
|
||||
"compose.noSubject": "(no subject)",
|
||||
"compose.from": "From",
|
||||
"compose.attach": "Attach",
|
||||
"compose.more": "More",
|
||||
"compose.expand": "Expand",
|
||||
"compose.collapse": "Collapse",
|
||||
"compose.popOut": "Pop out",
|
||||
"compose.replyLabel": "Reply",
|
||||
"compose.addRecipient": "Add recipient…",
|
||||
"compose.fmtBold": "Bold",
|
||||
"compose.fmtItalic": "Italic",
|
||||
"compose.fmtUnderline": "Underline",
|
||||
"compose.fmtStrike": "Strikethrough",
|
||||
"compose.fmtBulletList": "Bulleted list",
|
||||
"compose.fmtNumberList": "Numbered list",
|
||||
"compose.fmtQuote": "Quote",
|
||||
"compose.fmtLink": "Insert link",
|
||||
"compose.fmtAlignLeft": "Align left",
|
||||
"compose.fmtAlignCenter": "Align center",
|
||||
"compose.fmtAlignRight": "Align right",
|
||||
"compose.fmtClear": "Clear formatting",
|
||||
"compose.fmtColor": "Text color",
|
||||
"compose.fmtHighlight": "Highlight color",
|
||||
"compose.fmtImage": "Insert image",
|
||||
"compose.linkPrompt": "Link URL:",
|
||||
"compose.imageTooLarge": "Image is too large (max 5 MB).",
|
||||
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
|
||||
"compose.attachments": "Attachments",
|
||||
|
||||
"wizard.title": "Add mail account",
|
||||
"wizard.step1": "Your email address",
|
||||
"wizard.emailPlaceholder": "you@example.com",
|
||||
"wizard.discover": "Continue",
|
||||
"wizard.discovering": "Looking up settings…",
|
||||
"wizard.foundVia": "Found via {source}",
|
||||
"wizard.imapServer": "IMAP server",
|
||||
"wizard.smtpServer": "SMTP server",
|
||||
"wizard.host": "Host",
|
||||
"wizard.port": "Port",
|
||||
"wizard.security": "Security",
|
||||
"wizard.accountPassword": "Account password",
|
||||
"wizard.appPasswordHint": "For Gmail/Yahoo/iCloud use an app password.",
|
||||
"wizard.test": "Test & continue",
|
||||
"wizard.testing": "Testing connection…",
|
||||
"wizard.testOk": "Connection works.",
|
||||
"wizard.create": "Add account",
|
||||
"wizard.creating": "Adding…",
|
||||
"wizard.created": "Account added — syncing your mail.",
|
||||
"wizard.startReading": "Start reading now",
|
||||
"wizard.manualConfig": "Enter settings manually",
|
||||
"wizard.displayName": "Display name",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.profile": "Profile",
|
||||
"settings.security": "Security",
|
||||
"settings.accounts": "Accounts",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"settings.recoveryEmail": "Recovery email",
|
||||
"settings.twoFactor": "Two-factor authentication",
|
||||
"settings.twoFactorOn": "2FA is enabled.",
|
||||
"settings.twoFactorOff": "2FA is not enabled.",
|
||||
"settings.enable2fa": "Enable 2FA",
|
||||
"settings.disable2fa": "Disable 2FA",
|
||||
"settings.disable2faPrompt": "Enter your password to turn off 2FA:",
|
||||
"settings.disable": "Disable",
|
||||
"settings.twoFactorDisabled": "2FA has been turned off.",
|
||||
"settings.scanQr": "Scan this in your authenticator app, then enter a code:",
|
||||
"settings.secret": "Secret",
|
||||
"settings.confirm": "Confirm",
|
||||
"settings.sessions": "Active sessions",
|
||||
"settings.thisDevice": "this device",
|
||||
"settings.revoke": "Revoke",
|
||||
"settings.threadOrder": "Conversation order",
|
||||
"settings.timezone": "Time zone",
|
||||
"settings.addAccount": "Add account",
|
||||
"settings.syncState": "State: {state}",
|
||||
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
|
||||
"settings.theme": "Theme",
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.system": "System",
|
||||
"theme.toggle": "Toggle theme",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
export type Messages = Record<MessageKey, string>;
|
||||
@@ -0,0 +1,62 @@
|
||||
// Minimal, dependency-free i18n. English today; add a locale by registering a
|
||||
// catalog with the same keys. Strings are never hard-coded in components — they
|
||||
// go through t().
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { en, type MessageKey, type Messages } from "./en";
|
||||
|
||||
const catalogs: Record<string, Messages> = {
|
||||
en,
|
||||
// pl, <- add here to enable Polish, etc.
|
||||
};
|
||||
|
||||
export type Vars = Record<string, string | number>;
|
||||
export type TFunc = (key: MessageKey, vars?: Vars) => string;
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
t: TFunc;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
function interpolate(template: string, vars?: Vars): string {
|
||||
if (!vars) return template;
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [k, v]) => acc.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocale] = useState<string>(() => localStorage.getItem("locale") ?? "en");
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const catalog = catalogs[locale] ?? en;
|
||||
const t: TFunc = (key, vars) => interpolate(catalog[key] ?? en[key] ?? key, vars);
|
||||
return {
|
||||
locale,
|
||||
setLocale: (l) => {
|
||||
localStorage.setItem("locale", l);
|
||||
setLocale(l);
|
||||
},
|
||||
t,
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useT(): TFunc {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error("useT must be used within I18nProvider");
|
||||
return ctx.t;
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within I18nProvider");
|
||||
return { locale: ctx.locale, setLocale: ctx.setLocale };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Semantic theme tokens as "R G B" triplets (consumed by tailwind.config.js as
|
||||
rgb(var(--x) / <alpha>)). Light is the default; .dark on <html> flips them. */
|
||||
:root {
|
||||
--app-bg: 243 244 246; /* app chrome behind panels */
|
||||
--app-surface: 255 255 255; /* panels */
|
||||
--app-elevated: 255 255 255; /* popovers, composer */
|
||||
--app-border: 229 231 235;
|
||||
--app-fg: 24 27 32;
|
||||
--app-muted: 107 114 128;
|
||||
--app-faint: 156 163 175;
|
||||
--app-accent: 37 99 235;
|
||||
--app-accent-fg: 255 255 255;
|
||||
--app-hover: 243 244 246;
|
||||
--app-active: 224 233 254; /* selected row */
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--app-bg: 27 30 32; /* main background */
|
||||
--app-surface: 33 36 39; /* panels (slightly elevated) */
|
||||
--app-elevated: 39 42 46; /* popovers, composer */
|
||||
--app-border: 38 40 42; /* subtle dark dividers */
|
||||
--app-fg: 226 228 231;
|
||||
--app-muted: 150 156 164;
|
||||
--app-faint: 108 114 122;
|
||||
--app-accent: 59 130 246;
|
||||
--app-accent-fg: 255 255 255;
|
||||
--app-hover: 42 46 50;
|
||||
--app-active: 44 54 70;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: rgb(var(--app-bg));
|
||||
color: rgb(var(--app-fg));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Rich-text compose editor: quoted replies get depth-colored left borders
|
||||
(blue → green → amber), matching a Fastmail-style nested quote. */
|
||||
.wmc-editor {
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wmc-editor:empty::before {
|
||||
content: "";
|
||||
}
|
||||
.wmc-editor a {
|
||||
color: rgb(var(--app-accent));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wmc-editor ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.wmc-editor ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.wmc-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.wmc-editor blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.35em 0.9em;
|
||||
border-left: 2px solid #5b7fb9;
|
||||
border-radius: 2px;
|
||||
background: rgba(120, 140, 170, 0.06);
|
||||
}
|
||||
.wmc-editor blockquote blockquote {
|
||||
border-left-color: #6a9955;
|
||||
background: rgba(120, 160, 120, 0.06);
|
||||
}
|
||||
.wmc-editor blockquote blockquote blockquote {
|
||||
border-left-color: #c08a4a;
|
||||
background: rgba(180, 150, 100, 0.06);
|
||||
}
|
||||
|
||||
/* Thin, unobtrusive scrollbars that match the theme. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--app-border)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(var(--app-border));
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Typed API client. Sends the CSRF double-submit header on mutating requests
|
||||
// (SPEC §10.3) and includes cookies for the session (SPEC §6.1).
|
||||
|
||||
import type {
|
||||
AccountDetail,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
DiscoverResponse,
|
||||
DraftInput,
|
||||
DraftOut,
|
||||
MailboxOut,
|
||||
RecipientSuggestion,
|
||||
SearchOut,
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
SessionInfo,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
TotpSetup,
|
||||
UserOut,
|
||||
UserSettings,
|
||||
} from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function csrfToken(): string {
|
||||
return readCookie("__Host-csrf") ?? readCookie("csrf") ?? "";
|
||||
}
|
||||
|
||||
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
if (MUTATING.has(method)) {
|
||||
headers.set("X-CSRF", csrfToken());
|
||||
// FormData sets its own multipart Content-Type (with boundary); leave it be.
|
||||
const isForm = init.body instanceof FormData;
|
||||
if (init.body && !isForm && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
}
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (resp.status === 204) return undefined as T;
|
||||
const isJson = resp.headers.get("content-type")?.includes("application/json");
|
||||
const data = isJson ? await resp.json() : await resp.text();
|
||||
if (!resp.ok) {
|
||||
const err = (data as { error?: { code: string; message: string } })?.error;
|
||||
throw new ApiError(resp.status, err?.code ?? "error", err?.message ?? "request failed");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register: (localpart: string, password: string) =>
|
||||
apiFetch<UserOut>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ localpart, password }),
|
||||
}),
|
||||
login: (handle: string, password: string, totp?: string) =>
|
||||
apiFetch<UserOut>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ handle, password, totp: totp || undefined }),
|
||||
}),
|
||||
logout: () => apiFetch<{ status: string }>("/auth/logout", { method: "POST" }),
|
||||
me: () => apiFetch<UserOut>("/auth/me"),
|
||||
|
||||
accounts: () => apiFetch<AccountOut[]>("/accounts"),
|
||||
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
|
||||
|
||||
threads: (view = "unified", cursor?: string) => {
|
||||
const params = new URLSearchParams({ view });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return apiFetch<ThreadListOut>(`/threads?${params.toString()}`);
|
||||
},
|
||||
thread: (id: string, sort: "asc" | "desc" = "asc") =>
|
||||
apiFetch<ThreadDetail>(`/threads/${id}?sort=${sort}`),
|
||||
|
||||
search: (q: string, cursor?: string) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return apiFetch<SearchOut>(`/search?${params.toString()}`);
|
||||
},
|
||||
|
||||
messageHtmlUrl: (id: string) => `/api/messages/${id}/html`,
|
||||
|
||||
// --- account wizard ---
|
||||
discover: (email: string) =>
|
||||
apiFetch<DiscoverResponse>("/accounts/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
testAccount: (input: { imap: ServerConfig; smtp: ServerConfig; email: string; password: string }) =>
|
||||
apiFetch<{ ok: boolean }>("/accounts/test", { method: "POST", body: JSON.stringify(input) }),
|
||||
createAccount: (input: {
|
||||
email: string;
|
||||
display_name: string;
|
||||
color?: string;
|
||||
imap: ServerConfig;
|
||||
smtp: ServerConfig;
|
||||
password: string;
|
||||
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteAccount: (id: string) => apiFetch<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// --- identity ---
|
||||
totpSetup: () => apiFetch<TotpSetup>("/auth/totp/setup", { method: "POST" }),
|
||||
totpEnable: (code: string) =>
|
||||
apiFetch<{ status: string }>("/auth/totp/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
totpDisable: (password: string) =>
|
||||
apiFetch<{ status: string }>("/auth/totp/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
sessions: () => apiFetch<SessionInfo[]>("/auth/sessions"),
|
||||
revokeSession: (sid: string) =>
|
||||
apiFetch<{ status: string }>(`/auth/sessions/${sid}`, { method: "DELETE" }),
|
||||
|
||||
// --- settings ---
|
||||
settings: () => apiFetch<UserSettings>("/settings"),
|
||||
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
|
||||
// --- compose ---
|
||||
suggestRecipients: (q: string) =>
|
||||
apiFetch<RecipientSuggestion[]>(`/recipients/suggest?q=${encodeURIComponent(q)}`),
|
||||
createDraft: (input: DraftInput) =>
|
||||
apiFetch<DraftOut>("/drafts", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateDraft: (id: string, input: DraftInput) =>
|
||||
apiFetch<DraftOut>(`/drafts/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteDraft: (id: string) => apiFetch<void>(`/drafts/${id}`, { method: "DELETE" }),
|
||||
send: (draftId: string) =>
|
||||
apiFetch<SendResponse>("/send", { method: "POST", body: JSON.stringify({ draft_id: draftId }) }),
|
||||
undoSend: (outboxId: string) =>
|
||||
apiFetch<{ status: string }>(`/send/${outboxId}/undo`, { method: "POST" }),
|
||||
|
||||
// --- mail actions ---
|
||||
messageFlags: (id: string, add: string[], remove: string[]) =>
|
||||
apiFetch<{ id: string; flags: string[] }>(`/messages/${id}/flags`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ add, remove }),
|
||||
}),
|
||||
threadFlags: (id: string, add: string[], remove: string[]) =>
|
||||
apiFetch<{ thread_id: string; changed: number }>(`/threads/${id}/flags`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ add, remove }),
|
||||
}),
|
||||
moveMessage: (id: string, mailboxId: string) =>
|
||||
apiFetch<{ id: string; mailbox_id: string }>(`/messages/${id}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mailbox_id: mailboxId }),
|
||||
}),
|
||||
archiveThread: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/threads/${id}/archive`, { method: "POST" }),
|
||||
trashThread: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/threads/${id}/trash`, { method: "POST" }),
|
||||
restoreThread: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
|
||||
deleteThreadPermanent: (id: string) =>
|
||||
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
|
||||
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
|
||||
|
||||
// --- attachments ---
|
||||
messageAttachments: (messageId: string) =>
|
||||
apiFetch<AttachmentOut[]>(`/messages/${messageId}/attachments`),
|
||||
attachmentDownloadUrl: (messageId: string, attachmentId: string) =>
|
||||
`/api/messages/${messageId}/attachments/${attachmentId}?download=1`,
|
||||
attachmentsZipUrl: (messageId: string, ids?: string[]) =>
|
||||
`/api/messages/${messageId}/attachments.zip${ids && ids.length ? `?ids=${ids.join(",")}` : ""}`,
|
||||
uploadComposeAttachment: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return apiFetch<AttachmentOut>("/attachments", { method: "POST", body: form });
|
||||
},
|
||||
composeAttachments: (ids: string[]) =>
|
||||
ids.length
|
||||
? apiFetch<AttachmentOut[]>(`/attachments?ids=${ids.join(",")}`)
|
||||
: Promise.resolve<AttachmentOut[]>([]),
|
||||
allowRemoteImages: (id: string) =>
|
||||
apiFetch<{ status: string; sender: string }>(`/messages/${id}/allow-remote-images`, {
|
||||
method: "POST",
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
retry: (count, error) => {
|
||||
// Do not retry an unauthenticated response.
|
||||
if (error instanceof ApiError && error.status === 401) return false;
|
||||
return count < 2;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolved: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function apply(resolved: "light" | "dark") {
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem("theme") as Theme) || "system",
|
||||
);
|
||||
const [systemDark, setSystemDark] = useState(systemPrefersDark);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => setSystemDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const resolved: "light" | "dark" = theme === "system" ? (systemDark ? "dark" : "light") : theme;
|
||||
|
||||
useEffect(() => apply(resolved), [resolved]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
resolved,
|
||||
setTheme: (t) => {
|
||||
localStorage.setItem("theme", t);
|
||||
setThemeState(t);
|
||||
},
|
||||
}),
|
||||
[theme, resolved],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Hand-written mirrors of the backend API models (SPEC §9.1). The generated
|
||||
// client (`npm run gen:api` -> src/api/schema.ts) can supersede these in M1.
|
||||
|
||||
export interface Address {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UserOut {
|
||||
id: string;
|
||||
handle: string;
|
||||
recovery_email: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadSummary {
|
||||
id: string;
|
||||
subject: string;
|
||||
snippet: string;
|
||||
participants: Address[];
|
||||
account_ids: string[];
|
||||
message_count: number;
|
||||
unread_count: number;
|
||||
has_attachments: boolean;
|
||||
pinned: boolean;
|
||||
last_message_at: string;
|
||||
}
|
||||
|
||||
export interface ThreadListOut {
|
||||
threads: ThreadSummary[];
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
export interface AttachmentOut {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface MessageEnvelope {
|
||||
id: string;
|
||||
account_id: string;
|
||||
mailbox_id: string;
|
||||
thread_id: string | null;
|
||||
subject: string;
|
||||
from_addr: Address[];
|
||||
to_addr: Address[];
|
||||
cc_addr: Address[];
|
||||
date: string;
|
||||
flags: string[];
|
||||
is_unread: boolean;
|
||||
is_starred: boolean;
|
||||
has_attachments: boolean;
|
||||
has_remote_images: boolean;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
id: string;
|
||||
subject: string;
|
||||
order: "asc" | "desc";
|
||||
pinned: boolean;
|
||||
messages: MessageEnvelope[];
|
||||
}
|
||||
|
||||
export interface AccountOut {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface MailboxOut {
|
||||
id: string;
|
||||
account_id: string;
|
||||
path: string;
|
||||
special_use: string | null;
|
||||
}
|
||||
|
||||
export interface SearchThreadHit {
|
||||
thread: ThreadSummary;
|
||||
highlights: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SearchOut {
|
||||
threads: SearchThreadHit[];
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
// --- M1: account wizard ------------------------------------------------------
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: string; // ssl | starttls
|
||||
auth: string; // password | oauth2
|
||||
}
|
||||
|
||||
export interface CandidateConfig {
|
||||
imap: ServerConfig;
|
||||
smtp: ServerConfig;
|
||||
source: string;
|
||||
oauth_provider: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryStep {
|
||||
method: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DiscoverResponse {
|
||||
candidates: CandidateConfig[];
|
||||
steps: DiscoveryStep[];
|
||||
}
|
||||
|
||||
export interface AccountDetail extends AccountOut {
|
||||
state_detail: string;
|
||||
imap_config: Record<string, unknown>;
|
||||
smtp_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// --- M1: identity ------------------------------------------------------------
|
||||
|
||||
export interface TotpSetup {
|
||||
secret: string;
|
||||
provisioning_uri: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
sid: string;
|
||||
created: string;
|
||||
ua: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
thread_order: "asc" | "desc";
|
||||
tz: string;
|
||||
allowed_image_senders: string[];
|
||||
}
|
||||
|
||||
// --- M1: compose -------------------------------------------------------------
|
||||
|
||||
export interface RecipientSuggestion {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface DraftPayload {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
attachment_ids: string[];
|
||||
}
|
||||
|
||||
export interface DraftInput {
|
||||
account_id: string;
|
||||
thread_id?: string | null;
|
||||
reply_to_message_id?: string | null;
|
||||
kind: string;
|
||||
payload: DraftPayload;
|
||||
}
|
||||
|
||||
export interface DraftOut {
|
||||
id: string;
|
||||
account_id: string;
|
||||
thread_id: string | null;
|
||||
reply_to_message_id: string | null;
|
||||
kind: string;
|
||||
payload: DraftPayload;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SendResponse {
|
||||
outbox_id: string;
|
||||
undo_until: string;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
|
||||
// except Escape. Supports the `g i` chord (go to inbox).
|
||||
export interface Shortcuts {
|
||||
onSearch?: () => void;
|
||||
onCompose?: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onArchive?: () => void;
|
||||
onGoInbox?: () => void;
|
||||
onEscape?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
function isTyping(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
const ref = useRef(shortcuts);
|
||||
ref.current = shortcuts;
|
||||
const pendingG = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const s = ref.current;
|
||||
if (e.key === "Escape") {
|
||||
s.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (isTyping(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
if (pendingG.current) {
|
||||
pendingG.current = false;
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
s.onGoInbox?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
s.onSearch?.();
|
||||
break;
|
||||
case "c":
|
||||
s.onCompose?.();
|
||||
break;
|
||||
case "r":
|
||||
s.onReply?.();
|
||||
break;
|
||||
case "a":
|
||||
s.onReplyAll?.();
|
||||
break;
|
||||
case "f":
|
||||
s.onForward?.();
|
||||
break;
|
||||
case "e":
|
||||
s.onArchive?.();
|
||||
break;
|
||||
case "j":
|
||||
s.onNext?.();
|
||||
break;
|
||||
case "k":
|
||||
s.onPrev?.();
|
||||
break;
|
||||
case "g":
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 800);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// WebSocket layer (SPEC §9.2). Validates frames with zod and invalidates
|
||||
// TanStack Query caches keyed on ids in the payload.
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
const frameSchema = z.object({
|
||||
event: z.string(),
|
||||
payload: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
const ALLOWED_EVENTS = new Set([
|
||||
"message.new",
|
||||
"message.changed",
|
||||
"message.deleted",
|
||||
"message.sent",
|
||||
"flags.changed",
|
||||
"thread.updated",
|
||||
"sync.progress",
|
||||
"account.state_changed",
|
||||
"draft.saved",
|
||||
"outbox.state_changed",
|
||||
]);
|
||||
|
||||
export function connectWs(queryClient: QueryClient): () => void {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
|
||||
const ping = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = frameSchema.safeParse(parsed);
|
||||
if (!result.success || !ALLOWED_EVENTS.has(result.data.event)) return;
|
||||
const { event: name, payload } = result.data;
|
||||
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
if (typeof payload.thread_id === "string") {
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
||||
}
|
||||
}
|
||||
if (name === "account.state_changed") {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
}
|
||||
if (name === "message.sent") {
|
||||
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.clearInterval(ping);
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { Composer, draftToInit } from "../components/compose/Composer";
|
||||
import { useT } from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
// Standalone composer shown in a popped-out window. It loads the draft the parent
|
||||
// window persisted before opening this one.
|
||||
export function ComposeWindow() {
|
||||
const t = useT();
|
||||
const { draftId } = useParams<{ draftId: string }>();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["draft", draftId],
|
||||
queryFn: async () => {
|
||||
const drafts = await api.listDrafts();
|
||||
return drafts.find((d) => d.id === draftId) ?? null;
|
||||
},
|
||||
enabled: Boolean(draftId),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="grid h-screen place-items-center bg-app-bg text-app-faint">…</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="grid h-screen place-items-center bg-app-bg text-app-faint">
|
||||
{t("compose.noDrafts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-app-bg text-app-fg">
|
||||
<Composer init={draftToInit(data)} onClose={() => window.close()} fullWindow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
|
||||
export function Login() {
|
||||
const t = useT();
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [needTotp, setNeedTotp] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["me"], user);
|
||||
navigate("/mail");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) {
|
||||
setNeedTotp(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const error = mutation.error instanceof ApiError ? mutation.error : null;
|
||||
// Don't show the "code required" error as a failure once we've revealed the field.
|
||||
const showError = error && !(needTotp && error.code === "totp_required");
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-semibold">{t("auth.signInTitle")}</h1>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
placeholder={t("auth.handlePlaceholder")}
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
disabled={needTotp}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
type="password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={needTotp}
|
||||
/>
|
||||
{needTotp && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none tracking-widest"
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-app-muted">{t("auth.totpPrompt")}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{mutation.isPending ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link className="text-app-accent" to="/register">
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||
import { DraftsList } from "../components/list/DraftsList";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { MessageList } from "../components/list/MessageList";
|
||||
import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
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 { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
|
||||
// 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" },
|
||||
{ view: "folder:archive", icon: "archive", label: "nav.archive" },
|
||||
{ view: "folder:drafts", icon: "drafts", label: "nav.drafts" },
|
||||
{ view: "folder:sent", icon: "sent", label: "nav.sent" },
|
||||
{ view: "folder:junk", icon: "spam", label: "nav.spam" },
|
||||
{ view: "folder:trash", icon: "trash", label: "nav.trash" },
|
||||
];
|
||||
|
||||
export function MailLayout() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const match = useMatch("/mail/t/:threadId");
|
||||
const threadId = match?.params.threadId;
|
||||
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 [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Resizable panel widths (persisted). Drag the dividers to adjust.
|
||||
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
|
||||
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
|
||||
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
|
||||
// The list can be dragged up to 75% of the window width.
|
||||
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]);
|
||||
|
||||
const isDrafts = view === "folder:drafts";
|
||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
|
||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
const threadsQuery = useQuery({
|
||||
queryKey: ["threads", view],
|
||||
queryFn: () => api.threads(view),
|
||||
enabled: !isDrafts,
|
||||
});
|
||||
const draftsQuery = useQuery({
|
||||
queryKey: ["drafts"],
|
||||
queryFn: api.listDrafts,
|
||||
enabled: isDrafts,
|
||||
});
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", term],
|
||||
queryFn: () => api.search(term),
|
||||
enabled: term.trim().length > 0,
|
||||
});
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
|
||||
|
||||
const allThreads: ThreadSummary[] = term.trim()
|
||||
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
||||
: (threadsQuery.data?.threads ?? []);
|
||||
const filtered = allThreads.filter((th) =>
|
||||
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
|
||||
);
|
||||
// The backend returns threads newest-first; reverse for an oldest-first view.
|
||||
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
|
||||
|
||||
const threadsRef = useRef<ThreadSummary[]>(threads);
|
||||
threadsRef.current = threads;
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["thread", threadId, threadOrder],
|
||||
queryFn: () => api.thread(threadId!, threadOrder),
|
||||
enabled: Boolean(threadId),
|
||||
});
|
||||
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["threads"] }),
|
||||
});
|
||||
useEffect(() => {
|
||||
const detail = detailQuery.data;
|
||||
if (detail && detail.messages.some((m) => m.is_unread)) markRead.mutate(detail.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detailQuery.data?.id]);
|
||||
|
||||
const afterMove = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
navigate("/mail");
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (!threadId) return;
|
||||
await api.archiveThread(threadId);
|
||||
afterMove();
|
||||
};
|
||||
|
||||
const trash = async () => {
|
||||
if (!threadId) return;
|
||||
await api.trashThread(threadId);
|
||||
afterMove();
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
if (!threadId) return;
|
||||
await api.restoreThread(threadId);
|
||||
afterMove();
|
||||
};
|
||||
|
||||
const deletePermanent = async () => {
|
||||
if (!threadId) return;
|
||||
await api.deleteThreadPermanent(threadId);
|
||||
afterMove();
|
||||
};
|
||||
|
||||
const togglePin = async () => {
|
||||
const detail = detailQuery.data;
|
||||
if (!detail) return;
|
||||
await api.threadFlags(
|
||||
detail.id,
|
||||
detail.pinned ? [] : ["\\Flagged"],
|
||||
detail.pinned ? ["\\Flagged"] : [],
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
|
||||
};
|
||||
|
||||
const openRelative = (delta: number) => {
|
||||
const list = threadsRef.current;
|
||||
if (list.length === 0) return;
|
||||
const idx = list.findIndex((th) => th.id === threadId);
|
||||
const next = Math.min(Math.max((idx < 0 ? -1 : idx) + delta, 0), list.length - 1);
|
||||
navigate(`/mail/t/${list[next].id}`);
|
||||
};
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onSearch: () => searchRef.current?.focus(),
|
||||
onCompose: () => accountsQuery.data?.[0] && setComposing({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
||||
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
|
||||
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
onArchive: archive,
|
||||
onGoInbox: () => {
|
||||
setView("folder:inbox");
|
||||
navigate("/mail");
|
||||
},
|
||||
onEscape: () => setComposing(null),
|
||||
onNext: () => openRelative(1),
|
||||
onPrev: () => openRelative(-1),
|
||||
});
|
||||
|
||||
const accounts = accountsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{ width: sidebarW }}
|
||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("common.appName")}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
onClick={() => accounts[0] && setComposing({ accountId: accounts[0].id, kind: "new" })}
|
||||
>
|
||||
<Icon name="compose" size={16} />
|
||||
{t("nav.compose")}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
|
||||
{FOLDERS.map((f) => (
|
||||
<SidebarItem
|
||||
key={f.view}
|
||||
active={view === f.view}
|
||||
onClick={() => {
|
||||
setView(f.view);
|
||||
navigate("/mail");
|
||||
}}
|
||||
>
|
||||
<Icon name={f.icon} size={18} />
|
||||
{t(f.label)}
|
||||
</SidebarItem>
|
||||
))}
|
||||
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("settings.accounts")}
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
<SidebarItem
|
||||
key={a.id}
|
||||
active={view === `account:${a.id}`}
|
||||
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
<span className="truncate">{a.email}</span>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-app-border p-2 text-sm">
|
||||
<Link className="block rounded-lg px-3 py-2 text-app-muted hover:bg-app-hover hover:text-app-fg" to="/settings">
|
||||
⚙ {t("nav.settings")}
|
||||
</Link>
|
||||
<button
|
||||
className="block w-full rounded-lg px-3 py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
{t("common.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
|
||||
|
||||
{/* Message list */}
|
||||
<div
|
||||
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">
|
||||
<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"
|
||||
placeholder={t("nav.search")}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isDrafts && (
|
||||
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border px-3 text-sm">
|
||||
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
|
||||
{t("filter.all")}
|
||||
</FilterTab>
|
||||
<FilterTab active={filter === "unread"} onClick={() => setFilter("unread")}>
|
||||
{t("filter.unread")}
|
||||
</FilterTab>
|
||||
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
|
||||
{t("filter.pinned")}
|
||||
</FilterTab>
|
||||
<button
|
||||
className="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"))}
|
||||
>
|
||||
<SortIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
{isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
onOpen={(draft) => {
|
||||
setComposing(draftToInit(draft));
|
||||
setComposerExpanded(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MessageList threads={threads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
|
||||
|
||||
{/* Thread pane */}
|
||||
<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">
|
||||
{currentFolder === "archive" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={trash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : currentFolder === "trash" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={deletePermanent}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton onClick={archive}>
|
||||
<Icon name="archive" size={16} />
|
||||
{t("mail.archive")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={togglePin} active={detailQuery.data.pinned}>
|
||||
<Icon
|
||||
name="pin"
|
||||
size={16}
|
||||
className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""}
|
||||
/>
|
||||
{detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={trash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-app-faint">
|
||||
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ThreadView
|
||||
subject={detailQuery.data.subject}
|
||||
messages={detailQuery.data.messages}
|
||||
order={detailQuery.data.order}
|
||||
replyKindSignal={replySignal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-app-faint">
|
||||
{t("mail.selectConversation")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{composing && (
|
||||
<div
|
||||
className={
|
||||
composerExpanded
|
||||
? "absolute inset-0 z-30"
|
||||
: "absolute bottom-0 right-4 z-30 w-[47.5rem] max-w-[calc(100%-2rem)]"
|
||||
}
|
||||
>
|
||||
<Composer
|
||||
init={composing}
|
||||
expanded={composerExpanded}
|
||||
onToggleExpand={() => setComposerExpanded((v) => !v)}
|
||||
onClose={() => {
|
||||
setComposing(null);
|
||||
setComposerExpanded(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterTab({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex h-full items-center border-b-2 ${
|
||||
active
|
||||
? "border-app-accent font-semibold text-app-fg"
|
||||
: "border-transparent text-app-muted hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 3v10M3 5l2-2 2 2M11 13V3M9 11l2 2 2-2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-left ${
|
||||
active ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active = false,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
|
||||
active ? "text-app-fg" : "text-app-muted"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { api, ApiError } from "../lib/api";
|
||||
|
||||
export function Register() {
|
||||
const [localpart, setLocalpart] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.register(localpart, password),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["me"], user);
|
||||
navigate("/mail");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-semibold">Create account</h1>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
placeholder="handle (3–30 chars, a-z 0-9 . _ -)"
|
||||
value={localpart}
|
||||
onChange={(e) => setLocalpart(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
type="password"
|
||||
placeholder="password (min 8 chars)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create account"}
|
||||
</button>
|
||||
{mutation.error instanceof ApiError && (
|
||||
<p className="text-sm text-red-500">{mutation.error.message}</p>
|
||||
)}
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
Already have an account?{" "}
|
||||
<Link className="text-app-accent" to="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import { useMe } from "../../lib/auth";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
|
||||
export function Settings() {
|
||||
const t = useT();
|
||||
const params = useParams();
|
||||
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
|
||||
<Link to="/mail" className="text-sm text-app-accent">
|
||||
← {t("nav.mail")}
|
||||
</Link>
|
||||
<span className="font-semibold">{t("settings.title")}</span>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<nav className="w-52 shrink-0 border-r border-app-border p-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link
|
||||
key={s}
|
||||
to={`/settings/${s}`}
|
||||
className={`block rounded px-3 py-2 text-sm ${
|
||||
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(`settings.${s}` as const)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{section === "profile" && <ProfileSection />}
|
||||
{section === "security" && <SecuritySection />}
|
||||
{section === "accounts" && <AccountsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection() {
|
||||
const t = useT();
|
||||
const { data } = useMe();
|
||||
return (
|
||||
<section className="max-w-md 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 ?? "—"} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecuritySection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: me } = useMe();
|
||||
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
const [disablePw, setDisablePw] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const sessions = useQuery({ queryKey: ["sessions"], queryFn: api.sessions });
|
||||
|
||||
const enabled = Boolean(me?.totp_enabled);
|
||||
|
||||
const startSetup = async () => {
|
||||
const res = await api.totpSetup();
|
||||
setSetup({ secret: res.secret, uri: res.provisioning_uri });
|
||||
};
|
||||
const confirm = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.totpEnable(code);
|
||||
setSetup(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
const disable = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.totpDisable(disablePw);
|
||||
setDisabling(false);
|
||||
setDisablePw("");
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
const revoke = useMutation({
|
||||
mutationFn: (sid: string) => api.revokeSession(sid),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-md space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">{t("settings.twoFactor")}</h2>
|
||||
{enabled ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-emerald-500">{t("settings.twoFactorOn")}</p>
|
||||
{disabling ? (
|
||||
<div className="space-y-2 rounded-lg border border-app-border p-3">
|
||||
<p className="text-sm text-app-muted">{t("settings.disable2faPrompt")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
||||
type="password"
|
||||
value={disablePw}
|
||||
onChange={(e) => setDisablePw(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="rounded bg-red-600 px-3 py-1 text-sm text-white" onClick={disable}>
|
||||
{t("settings.disable")}
|
||||
</button>
|
||||
<button className="px-2 text-sm text-app-muted" onClick={() => setDisabling(false)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
|
||||
onClick={() => setDisabling(true)}
|
||||
>
|
||||
{t("settings.disable2fa")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : setup ? (
|
||||
<div className="space-y-3 rounded-lg border border-app-border p-3">
|
||||
<p className="text-sm text-app-muted">{t("settings.scanQr")}</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0 rounded bg-white p-2 ring-1 ring-gray-200">
|
||||
<QRCodeSVG value={setup.uri} size={160} level="M" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-app-muted">
|
||||
{t("settings.secret")}:
|
||||
<br />
|
||||
<code className="break-all">{setup.secret}</code>
|
||||
</p>
|
||||
<details className="text-xs text-app-faint">
|
||||
<summary className="cursor-pointer">otpauth URI</summary>
|
||||
<div className="mt-1 break-all rounded bg-app-surface px-2 py-1">{setup.uri}</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="w-32 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={confirm}>
|
||||
{t("settings.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg" onClick={startSetup}>
|
||||
{t("settings.enable2fa")}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
|
||||
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
||||
{(sessions.data ?? []).map((s) => (
|
||||
<li key={s.sid} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span>
|
||||
{s.ua || "session"}{" "}
|
||||
{s.current && <span className="text-xs text-emerald-500">({t("settings.thisDevice")})</span>}
|
||||
<span className="block text-xs text-app-faint">
|
||||
{new Date(s.created).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
{!s.current && (
|
||||
<button className="text-xs text-red-500" onClick={() => revoke.mutate(s.sid)}>
|
||||
{t("settings.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountsSection() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.deleteAccount(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("settings.accounts")}</h2>
|
||||
<button
|
||||
className="rounded bg-app-accent px-3 py-1.5 text-sm text-app-accent-fg"
|
||||
onClick={() => navigate("/settings/accounts/new")}
|
||||
>
|
||||
{t("settings.addAccount")}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-md 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>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
{(["light", "dark", "system"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded px-3 py-1 ${
|
||||
theme === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(`theme.${value}` as const)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
||||
<select
|
||||
className="rounded border border-app-border bg-app-surface px-2 py-1"
|
||||
value={settings.data?.thread_order ?? "asc"}
|
||||
onChange={(e) => patch.mutate({ thread_order: e.target.value as "asc" | "desc" })}
|
||||
>
|
||||
<option value="asc">{t("mail.orderOldest")}</option>
|
||||
<option value="desc">{t("mail.orderNewest")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { ServerConfig } from "../../lib/types";
|
||||
|
||||
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
|
||||
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
|
||||
|
||||
export function AccountWizard() {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [email, setEmail] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [color, setColor] = useState(PALETTE[0]);
|
||||
const [imap, setImap] = useState<ServerConfig>(EMPTY);
|
||||
const [smtp, setSmtp] = useState<ServerConfig>({ ...EMPTY, port: 465 });
|
||||
const [source, setSource] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tested, setTested] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const discover = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.discover(email);
|
||||
const cand = res.candidates[0];
|
||||
if (cand) {
|
||||
setImap({ ...cand.imap });
|
||||
setSmtp({ ...cand.smtp });
|
||||
setSource(cand.source);
|
||||
} else {
|
||||
setSource("manual");
|
||||
}
|
||||
setStep(2);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setTested(false);
|
||||
try {
|
||||
await api.testAccount({ imap, smtp, email, password });
|
||||
setTested(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.createAccount({
|
||||
email,
|
||||
display_name: displayName || email,
|
||||
color,
|
||||
imap,
|
||||
smtp,
|
||||
password,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setStep(3);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const serverFields = (
|
||||
cfg: ServerConfig,
|
||||
set: (c: ServerConfig) => void,
|
||||
label: string,
|
||||
) => (
|
||||
<fieldset className="rounded-lg border border-app-border p-3">
|
||||
<legend className="px-1 text-xs text-app-muted">{label}</legend>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
||||
placeholder={t("wizard.host")}
|
||||
value={cfg.host}
|
||||
onChange={(e) => set({ ...cfg, host: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-20 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
||||
type="number"
|
||||
value={cfg.port}
|
||||
onChange={(e) => set({ ...cfg, port: Number(e.target.value) })}
|
||||
/>
|
||||
<select
|
||||
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
|
||||
value={cfg.tls}
|
||||
onChange={(e) => set({ ...cfg, tls: e.target.value })}
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-lg px-4">
|
||||
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
|
||||
|
||||
{step === 1 && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
discover();
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm text-app-muted">{t("wizard.step1")}</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2"
|
||||
type="email"
|
||||
placeholder={t("wizard.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
disabled={busy || !email.includes("@")}
|
||||
>
|
||||
{busy ? t("wizard.discovering") : t("wizard.discover")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{source && source !== "manual" && (
|
||||
<p className="text-sm text-emerald-500">{t("wizard.foundVia", { source })}</p>
|
||||
)}
|
||||
{serverFields(imap, setImap, t("wizard.imapServer"))}
|
||||
{serverFields(smtp, setSmtp, t("wizard.smtpServer"))}
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
placeholder={t("wizard.displayName")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
|
||||
type="password"
|
||||
placeholder={t("wizard.accountPassword")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setTested(false);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-app-muted">Color</span>
|
||||
{PALETTE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-offset-1" : ""}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
|
||||
<div className="flex gap-2">
|
||||
{!tested ? (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={test}
|
||||
disabled={busy || !password || !imap.host}
|
||||
>
|
||||
{busy ? t("wizard.testing") : t("wizard.test")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
onClick={create}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("wizard.creating") : t("wizard.create")}
|
||||
</button>
|
||||
)}
|
||||
<button className="px-3 py-2 text-sm text-app-muted" onClick={() => setStep(1)}>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-emerald-500">{t("wizard.created")}</p>
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
|
||||
onClick={() => navigate("/mail")}
|
||||
>
|
||||
{t("wizard.startReading")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { I18nProvider } from "../i18n";
|
||||
import type { MessageEnvelope } from "../lib/types";
|
||||
|
||||
function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
||||
return {
|
||||
id,
|
||||
account_id: "acc",
|
||||
mailbox_id: "mb",
|
||||
thread_id: "t",
|
||||
subject: "Subject",
|
||||
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
|
||||
to_addr: [],
|
||||
cc_addr: [],
|
||||
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
|
||||
flags: unread ? [] : ["\\Seen"],
|
||||
is_unread: unread,
|
||||
is_starred: false,
|
||||
has_attachments: false,
|
||||
has_remote_images: false,
|
||||
snippet: `snippet ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ASC input always (SPEC §9.4): oldest first.
|
||||
const MESSAGES = [msg("a", 0), msg("b", 1), msg("c", 2)];
|
||||
|
||||
function wrap(children: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderThread(order: "asc" | "desc") {
|
||||
return render(wrap(<ThreadView subject="Subject" messages={MESSAGES} order={order} />));
|
||||
}
|
||||
|
||||
function messageOrder(): string[] {
|
||||
return screen.getAllByTestId("message").map((el) => el.getAttribute("data-message-id")!);
|
||||
}
|
||||
|
||||
describe("ThreadView direction-agnostic layout (SPEC §9.4)", () => {
|
||||
it("renders oldest-first when order=asc", () => {
|
||||
renderThread("asc");
|
||||
expect(messageOrder()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("renders newest-first when order=desc", () => {
|
||||
renderThread("desc");
|
||||
expect(messageOrder()).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("attaches the composer directly after the newest message in both orders", () => {
|
||||
// asc: newest 'c' is last -> composer at the very bottom
|
||||
const asc = renderThread("asc");
|
||||
const ascNodes = Array.from(
|
||||
asc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'),
|
||||
);
|
||||
const ascComposerIdx = ascNodes.findIndex(
|
||||
(n) => n.getAttribute("data-testid") === "composer-anchor",
|
||||
);
|
||||
const ascNewestIdx = ascNodes.findIndex((n) => n.getAttribute("data-message-id") === "c");
|
||||
expect(ascComposerIdx).toBe(ascNewestIdx + 1);
|
||||
expect(ascComposerIdx).toBe(ascNodes.length - 1); // bottom
|
||||
|
||||
// desc: newest 'c' is first -> composer right after it (top), above older
|
||||
const desc = renderThread("desc");
|
||||
const descNodes = Array.from(
|
||||
desc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'),
|
||||
);
|
||||
const descComposerIdx = descNodes.findIndex(
|
||||
(n) => n.getAttribute("data-testid") === "composer-anchor",
|
||||
);
|
||||
const descNewestIdx = descNodes.findIndex((n) => n.getAttribute("data-message-id") === "c");
|
||||
expect(descComposerIdx).toBe(descNewestIdx + 1);
|
||||
expect(descNewestIdx).toBe(0); // newest at top
|
||||
});
|
||||
|
||||
it("keeps unread messages expanded but read (non-newest) ones collapsed", () => {
|
||||
const messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)];
|
||||
const { container } = render(wrap(<ThreadView subject="Subject" messages={messages} order="asc" />));
|
||||
// An expanded message renders a MessageBody (iframe/loading/error); collapsed does not.
|
||||
const aNode = screen.getByText("snippet a").closest('[data-testid="message"]') as HTMLElement;
|
||||
const bNode = screen.getByText("snippet b").closest('[data-testid="message"]') as HTMLElement;
|
||||
const hasBody = (el: HTMLElement) =>
|
||||
el.querySelector("iframe") !== null ||
|
||||
/Loading thread|Couldn't load/i.test(el.textContent ?? "");
|
||||
expect(hasBody(aNode)).toBe(false); // read, not newest -> collapsed
|
||||
expect(hasBody(bNode)).toBe(true); // unread -> expanded
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user