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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+73
View File
@@ -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>
);
}
+6
View File
@@ -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} />;
}
+62
View File
@@ -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] ?? "" }}
/>
);
}
+32
View File
@@ -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>
);
}
+18
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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>
);
}
+183
View File
@@ -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>;
+62
View File
@@ -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 };
}
+105
View File
@@ -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;
}
+203
View File
@@ -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",
}),
};
+15
View File
@@ -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;
},
});
}
+57
View File
@@ -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;
}
+184
View File
@@ -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;
}
+83
View File
@@ -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);
}, []);
}
+65
View File
@@ -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();
};
}
+27
View File
@@ -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>,
);
+39
View File
@@ -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>
);
}
+91
View File
@@ -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>
);
}
+454
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+302
View File
@@ -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>
);
}
+100
View File
@@ -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();
});
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />