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
+154
View File
@@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useMail } from "@/store/mail";
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve";
import { Dialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Id } from "@/jmap/types";
export interface RuleDialogProps {
rule: SieveRule;
onClose: () => void;
/** Called with the rule and whether the user asked to apply it to existing messages now. */
onSave: (r: SieveRule, applyNow: boolean) => void;
/** When set, offers "Also apply to existing messages in <folder>". */
applyMailbox?: { id: Id; name: string } | null;
title?: string;
saveLabel?: string;
}
export function RuleDialog({ rule, onClose, onSave, applyMailbox, title, saveLabel }: RuleDialogProps) {
const [r, setR] = useState<SieveRule>(rule);
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox));
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);
const setTest = (i: number, t: SieveTest) => setR({ ...r, tests: r.tests.map((x, j) => (j === i ? t : x)) });
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
return (
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
{applyMailbox && (
<label className="check left" style={{ marginRight: "auto" }}>
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>Rule name</label><input className="input" value={r.name} onChange={(e) => setR({ ...r, name: e.target.value })} autoFocus /></div>
<div className="row" style={{ marginBottom: 8 }}>
<span className="label">When</span>
<select className="select" style={{ width: "auto" }} value={r.join} onChange={(e) => setR({ ...r, join: e.target.value as "allof" | "anyof" })}>
<option value="allof">all of the following match</option>
<option value="anyof">any of the following match</option>
</select>
</div>
{r.tests.map((t, i) => (
<div key={i} className="rule-row">
<select className="select" value={t.type === "true" ? "true" : t.type === "size" ? "size" : t.type === "body" ? "body" : t.type === "address" ? "address" : HEADER_CHOICES.some((h) => h.value === t.header) ? t.header : "__custom__"} onChange={(e) => {
const v = e.target.value;
if (v === "size") setTest(i, { type: "size", op: "over", value: 1024 * 1024 });
else if (v === "body") setTest(i, { type: "body", op: "contains", value: "" });
else if (v === "true") setTest(i, { type: "true" });
else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" });
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
}}>
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)}
<option value="address">Sender domain</option>
<option value="size">Message size</option>
<option value="body">Body text</option>
<option value="true">Always (all messages)</option>
</select>
{t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__") ? (
<input className="input" placeholder="Header name" value={t.header} onChange={(e) => setTest(i, { ...t, header: e.target.value })} />
) : t.type === "size" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "over" | "under" })}><option value="over">is larger than</option><option value="under">is smaller than</option></select>
) : t.type === "body" ? (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">contains</option><option value="notcontains">does not contain</option></select>
) : t.type === "true" ? <span /> : (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{t.type === "size" ? (
<div className="row"><input className="input" type="number" min={1} value={Math.round(t.value / 1024)} onChange={(e) => setTest(i, { ...t, value: Number(e.target.value) * 1024 })} /><span className="muted">KB</span></div>
) : t.type === "true" ? <span /> : t.type === "header" && (t.op === "exists" || t.op === "notexists") ? <span /> : (
<input className="input" placeholder={t.type === "address" ? "example.com" : "value"} value={(t as { value: string }).value} onChange={(e) => setTest(i, { ...t, value: e.target.value } as SieveTest)} />
)}
<button className="icon-btn sm danger" aria-label="Remove condition" onClick={() => setR({ ...r, tests: r.tests.filter((_, j) => j !== i) })} disabled={r.tests.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">Then</span></div>
{r.actions.map((a, i) => (
<div key={i} className="rule-row actions">
<select className="select" value={a.type} onChange={(e) => {
const v = e.target.value as SieveAction["type"];
const next: SieveAction = v === "fileinto" ? { type: "fileinto", mailbox: folders[0]?.path ?? "INBOX" } : v === "redirect" ? { type: "redirect", address: "" } : v === "reject" ? { type: "reject", reason: "" } : v === "addflag" ? { type: "addflag", flag: "" } : ({ type: v } as SieveAction);
setAction(i, next);
}}>
<option value="fileinto">Move to folder</option>
<option value="markread">Mark as read</option>
<option value="flag">Star</option>
<option value="addflag">Add label / keyword</option>
<option value="redirect">Forward to</option>
<option value="keep">Keep in Inbox</option>
<option value="discard">Delete</option>
<option value="reject">Reject with message</option>
<option value="stop">Stop processing more rules</option>
</select>
{a.type === "fileinto" ? (
<div className="row">
<select
className="select"
value={a.mailbox}
onChange={async (e) => {
const v = e.target.value;
if (v === "__new__") {
// Create a folder on the fly ("Parent/Child" creates nested folders).
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
if (!name?.trim()) return;
try {
const mail = useMail.getState();
const parts = name.split("/").map((x) => x.trim()).filter(Boolean);
let parentId: string | null = null;
for (const part of parts) {
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
parentId = existing ? existing.id : await mail.createMailbox(part, parentId);
}
const path = useMail.getState().mailboxPath(parentId!);
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
toast.success(`Folder “${path}” created`);
} catch (err) {
toast.error((err as Error).message);
}
return;
}
setAction(i, { ...a, mailbox: v, mailboxId: folders.find((f) => f.path === v)?.id });
}}
>
{folders.map((f) => <option key={f.id} value={f.path}>{f.path}</option>)}
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
<option value="__new__"> New folder</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder="[email protected]" value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder="Reason" value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
) : a.type === "addflag" || a.type === "setflag" || a.type === "removeflag" ? (
<input className="input" placeholder="keyword (e.g. $important, work)" value={a.flag} onChange={(e) => setAction(i, { ...a, flag: e.target.value })} />
) : <span />}
<button className="icon-btn sm danger" aria-label="Remove action" onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
</Dialog>
);
}