ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit c17887e48e
162 changed files with 20398 additions and 1072 deletions
+56
View File
@@ -0,0 +1,56 @@
import { useRef } from "react";
interface Props {
direction: "vertical" | "horizontal"; // vertical = a vertical bar that resizes width
onResize: (delta: number) => void;
onEnd?: () => void;
onReset?: () => void;
ariaLabel?: string;
}
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Props) {
const last = useRef(0);
const active = useRef(false);
return (
<div
className={`splitter ${direction}`}
role="separator"
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
aria-label={ariaLabel ?? "Resize panes"}
tabIndex={0}
onDoubleClick={onReset}
onPointerDown={(e) => {
e.preventDefault();
active.current = true;
last.current = direction === "vertical" ? e.clientX : e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
document.body.style.cursor = direction === "vertical" ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
}}
onPointerMove={(e) => {
if (!active.current) return;
const pos = direction === "vertical" ? e.clientX : e.clientY;
const delta = pos - last.current;
if (delta) {
last.current = pos;
onResize(delta);
}
}}
onPointerUp={(e) => {
if (!active.current) return;
active.current = false;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
document.body.style.cursor = "";
document.body.style.userSelect = "";
onEnd?.();
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24);
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24);
}}
>
<span className="splitter-grip" />
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { create } from "zustand";
interface DialogProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
footer?: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
closeOnBackdrop?: boolean;
className?: string;
}
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prev = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab" && ref.current) {
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]');
if (!focusables.length) return;
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", onKey, true);
// autofocus first input
window.setTimeout(() => {
const el = ref.current?.querySelector<HTMLElement>("[autofocus],input,textarea,select,button.btn-primary");
el?.focus();
}, 10);
return () => {
document.removeEventListener("keydown", onKey, true);
prev?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="dialog-backdrop"
onMouseDown={(e) => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
}}
>
<div className={`dialog ${size} ${className ?? ""}`} role="dialog" aria-modal="true" ref={ref}>
{title !== undefined && (
<div className="dialog-head">
<h2>{title}</h2>
<button className="icon-btn" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
)}
<div className="dialog-body">{children}</div>
{footer && <div className="dialog-foot">{footer}</div>}
</div>
</div>,
document.body,
);
}
/* ---------- Imperative confirm / prompt ---------- */
interface ConfirmRequest {
id: number;
kind: "confirm" | "prompt";
title: string;
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
defaultValue?: string;
placeholder?: string;
resolve: (v: boolean | string | null) => void;
}
const useConfirmStore = create<{ queue: ConfirmRequest[]; push(r: ConfirmRequest): void; pop(): void }>((set, get) => ({
queue: [],
push: (r) => set({ queue: [...get().queue, r] }),
pop: () => set({ queue: get().queue.slice(1) }),
}));
let reqId = 1;
export function confirmDialog(opts: { title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean }): Promise<boolean> {
return new Promise((resolve) => {
useConfirmStore.getState().push({ id: reqId++, kind: "confirm", ...opts, resolve: (v) => resolve(Boolean(v)) });
});
}
export function promptDialog(opts: { title: string; message?: ReactNode; defaultValue?: string; placeholder?: string; confirmLabel?: string }): Promise<string | null> {
return new Promise((resolve) => {
useConfirmStore.getState().push({ id: reqId++, kind: "prompt", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
});
}
export function ConfirmHost() {
const req = useConfirmStore((s) => s.queue[0]);
const pop = useConfirmStore((s) => s.pop);
const [value, setValue] = useState("");
useEffect(() => setValue(req?.defaultValue ?? ""), [req?.id, req?.defaultValue]);
if (!req) return null;
const done = (v: boolean | string | null) => {
req.resolve(v);
pop();
};
return (
<Dialog
open
onClose={() => done(req.kind === "prompt" ? null : false)}
title={req.title}
size="sm"
footer={
<>
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
{req.cancelLabel ?? "Cancel"}
</button>
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
</button>
</>
}
>
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
{req.kind === "prompt" && (
<form
onSubmit={(e) => {
e.preventDefault();
done(value);
}}
>
<input className="input" autoFocus value={value} placeholder={req.placeholder} onChange={(e) => setValue(e.target.value)} />
</form>
)}
</Dialog>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState, type ReactNode } from "react";
import type { EmailAddress } from "@/jmap/types";
import { avatarColor, initials } from "@/lib/address";
import { useContacts } from "@/store/contacts";
import { contactPhoto } from "@/lib/contacts";
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
const email = typeof who === "string" ? who : (who?.email ?? "");
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
const photo = useContacts((s) => {
if (!email || !s.loaded) return null;
const c = s.lookupByEmail(email);
return c && s.accountId ? contactPhoto(c, s.accountId) : null;
});
return (
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
{photo ? <img src={photo} alt="" loading="lazy" /> : initials({ name, email })}
</span>
);
}
export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) {
const sw = (
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && onChange(!checked)} disabled={disabled} />
);
if (!label) return sw;
return (
<div className="switch-row">
<div className="switch-text">
<span>{label}</span>
{hint && <span className="hint">{hint}</span>}
</div>
{sw}
</div>
);
}
export function Spinner({ size = "md", label }: { size?: "md" | "lg"; label?: string }) {
return (
<div className="row" style={{ justifyContent: "center", padding: 16, gap: 10 }}>
<span className={`spinner ${size === "lg" ? "lg" : ""}`} />
{label && <span className="muted">{label}</span>}
</div>
);
}
export function Empty({ icon, title, children }: { icon?: ReactNode; title: string; children?: ReactNode }) {
return (
<div className="empty">
{icon}
<h3>{title}</h3>
{children && <p>{children}</p>}
</div>
);
}
export function useMediaQuery(q: string): boolean {
const [m, setM] = useState(() => window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const fn = () => setM(mq.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, [q]);
return m;
}
export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
export const useIsNarrow = () => useMediaQuery("(max-width: 900px)");
export function Kbd({ keys }: { keys: string }) {
return (
<span className="keys">
{keys.split(" ").map((k, i) => (
<span key={i}>
{i > 0 && <span className="muted" style={{ margin: "0 3px" }}>then</span>}
{k.split("+").map((p, j) => (
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
</kbd>
))}
</span>
))}
</span>
);
}
export function ColorSwatches({ value, onChange, colors }: { value: string | null | undefined; onChange: (c: string) => void; colors?: string[] }) {
const list = colors ?? CALENDAR_COLORS;
return (
<div className="swatches">
{list.map((c) => (
<button key={c} type="button" className={`swatch ${value?.toLowerCase() === c ? "active" : ""}`} style={{ background: c }} onClick={() => onChange(c)} aria-label={c} />
))}
</div>
);
}
export const CALENDAR_COLORS = ["#0f766e", "#2563eb", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0891b2", "#4b5563", "#9333ea", "#be123c"];
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type CSSProperties } from "react";
import { createPortal } from "react-dom";
export interface Anchor {
x: number;
y: number;
w?: number;
h?: number;
}
export function anchorFromEl(el: Element | null): Anchor | null {
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
}
interface PopoverProps {
anchor: Anchor | null;
onClose: () => void;
children: ReactNode;
className?: string;
align?: "start" | "end";
/** Prefer opening below (default) or above. */
side?: "bottom" | "top" | "right";
width?: number | string;
style?: CSSProperties;
closeOnClick?: boolean;
role?: string;
}
/** Generic anchored popover rendered in a portal; closes on outside click / Escape. */
export function Popover({ anchor, onClose, children, className, align = "start", side = "bottom", width, style, closeOnClick = true, role = "menu" }: PopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null);
useLayoutEffect(() => {
if (!anchor || !ref.current) return;
const el = ref.current;
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = el.getBoundingClientRect();
const aw = anchor.w ?? 0;
const ah = anchor.h ?? 0;
let left = align === "end" ? anchor.x + aw - rect.width : anchor.x;
let top = side === "top" ? anchor.y - rect.height - 4 : anchor.y + ah + 4;
if (side === "right") {
left = anchor.x + aw + 4;
top = anchor.y;
}
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
if (left < 8) left = 8;
let maxHeight = Math.min(vh - 16, 560);
if (top + rect.height > vh - 8) {
// flip above if there is room, else clamp
const above = anchor.y - rect.height - 4;
if (above >= 8 && side !== "right") top = above;
else {
top = Math.max(8, vh - rect.height - 8);
maxHeight = vh - top - 8;
}
}
if (top < 8) top = 8;
setPos({ left, top, maxHeight });
}, [anchor, align, side]);
useEffect(() => {
if (!anchor) return;
const onDown = (e: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
const onScroll = () => onClose();
// Defer so the opening click doesn't immediately close.
const t = window.setTimeout(() => {
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("keydown", onKey, true);
window.addEventListener("resize", onScroll);
}, 0);
return () => {
window.clearTimeout(t);
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("touchstart", onDown, true);
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("resize", onScroll);
};
}, [anchor, onClose]);
if (!anchor) return null;
return createPortal(
<div
ref={ref}
role={role}
className={`popover ${className ?? ""}`}
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
onClick={(e) => {
if (closeOnClick && (e.target as HTMLElement).closest(".menu-item")) onClose();
}}
>
{children}
</div>,
document.body,
);
}
export interface MenuItemProps {
icon?: ReactNode;
label: ReactNode;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
kbd?: string;
active?: boolean;
checked?: boolean;
}
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
return (
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
<span className="grow truncate">{label}</span>
{kbd && <span className="menu-kbd">{kbd}</span>}
</button>
);
}
export function MenuSep() {
return <div className="menu-sep" />;
}
export function MenuTitle({ children }: { children: ReactNode }) {
return <div className="menu-title">{children}</div>;
}
/** Hook to manage a menu anchored to a trigger element. */
export function useMenu() {
const [anchor, setAnchor] = useState<Anchor | null>(null);
return {
anchor,
open: (e: { currentTarget: Element } | Element) => setAnchor(anchorFromEl("currentTarget" in e ? e.currentTarget : e)),
openAt: (x: number, y: number) => setAnchor({ x, y, w: 0, h: 0 }),
close: () => setAnchor(null),
isOpen: anchor !== null,
};
}
/** Simple tooltip via title-like hover with delay. */
export function Tooltip({ text, children }: { text: string; children: ReactNode }) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const timer = useRef<number | null>(null);
return (
<span
style={{ display: "inline-flex" }}
onMouseEnter={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
timer.current = window.setTimeout(() => setPos({ x: r.left + r.width / 2, y: r.bottom + 6 }), 500);
}}
onMouseLeave={() => {
if (timer.current) window.clearTimeout(timer.current);
setPos(null);
}}
onMouseDown={() => {
if (timer.current) window.clearTimeout(timer.current);
setPos(null);
}}
>
{children}
{pos && createPortal(<div className="tooltip" style={{ left: Math.max(8, Math.min(pos.x, window.innerWidth - 8)), top: pos.y, transform: "translateX(-50%)" }}>{text}</div>, document.body)}
</span>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { create } from "zustand";
import { X } from "lucide-react";
export interface Toast {
id: number;
message: string;
kind: "info" | "error" | "success";
action?: { label: string; onClick: () => void | Promise<void> };
duration: number;
progress?: boolean;
}
interface ToastState {
toasts: Toast[];
push(t: Omit<Toast, "id">): number;
dismiss(id: number): void;
}
let counter = 1;
const timers = new Map<number, number>();
export const useToasts = create<ToastState>((set, get) => ({
toasts: [],
push(t) {
const id = counter++;
set({ toasts: [...get().toasts.slice(-3), { ...t, id }] });
if (t.duration > 0) {
const timer = window.setTimeout(() => get().dismiss(id), t.duration);
timers.set(id, timer);
}
return id;
},
dismiss(id) {
const t = timers.get(id);
if (t) window.clearTimeout(t);
timers.delete(id);
set({ toasts: get().toasts.filter((x) => x.id !== id) });
},
}));
export const toast = {
show(message: string, opts: { action?: Toast["action"]; duration?: number; kind?: Toast["kind"]; progress?: boolean } = {}): number {
return useToasts.getState().push({ message, kind: opts.kind ?? "info", action: opts.action, duration: opts.duration ?? (opts.action ? 7000 : 4000), progress: opts.progress });
},
success(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
return toast.show(message, { ...opts, kind: "success" });
},
error(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
return toast.show(message, { ...opts, kind: "error", duration: opts.duration ?? 8000 });
},
dismiss(id: number) {
useToasts.getState().dismiss(id);
},
};
export function ToastHost() {
const toasts = useToasts((s) => s.toasts);
const dismiss = useToasts((s) => s.dismiss);
if (!toasts.length) return null;
return (
<div className="toast-host" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast toast-${t.kind}`}>
<span className="toast-msg">{t.message}</span>
{t.action && (
<button
className="toast-action"
onClick={() => {
void t.action!.onClick();
dismiss(t.id);
}}
>
{t.action.label}
</button>
)}
<button className="toast-close" aria-label="Dismiss" onClick={() => dismiss(t.id)}>
<X size={16} />
</button>
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
</div>
))}
</div>
);
}