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
@@ -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>
);
}