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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user