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 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+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>
);
}