Files
ihasmail/web/src/views/settings/RuleDialog.tsx
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.

  lib/mailbox/  archiveDate, emptyFolder, folderMove, labelTree,
                mailboxName, mailboxRoute
  lib/sieve/    sieve, sieveApply, sieveFolders
  lib/input/    keyboard, swipe, touch, listSelection, dropUpload
  lib/notify/   notify, webpush, webpushEnable
  lib/sw/       swCache, swFacts, staleBuild
  lib/text/     html, markdown, text, emlName

FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:

  - appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
    *Files*, where the client keeps signature images and synced settings.
    It stays flat.
  - format holds no formatting of text. It re-exports the date and clock
    formatters, so it belongs with dates/datetime, not with text/.
  - preview is the file viewer deciding what it can show without
    downloading, and source is where to point someone asking for this
    instance's AGPL source. Neither is about text.
  - notify is not Web Push. It is the tab title, the favicon badge and
    the new-mail sound -- in-app notification, which is why it sits with
    webpush rather than under sw/ with the service worker's own concerns.

threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.

No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
2026-09-15 23:17:50 -07:00

164 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/sieve";
import { Dialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Id } from "@/jmap/types";
import { t as translate } from "@/lib/i18n";
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;
/** Ticks that offer by default. Off unless applying is the point of the dialog. */
applyByDefault?: boolean;
title?: string;
saveLabel?: string;
}
export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault, title, saveLabel }: RuleDialogProps) {
const [r, setR] = useState<SieveRule>(rule);
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox && applyByDefault));
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" ? translate("New rule") : translate("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>{translate("Also apply to existing messages in")} <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
<div className="field"><label>{translate("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">{translate("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">{translate("all of the following match")}</option>
<option value="anyof">{translate("any of the following match")}</option>
</select>
</div>
{r.tests.map((t, i) => {
// A header the dropdown doesn't list needs a box to type its name in,
// which takes a column of its own — the comparator keeps its.
const customHeader = t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__");
return (
<div key={i} className={`rule-row${customHeader ? " named-header" : ""}`}>
<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}>{translate(h.label)}</option>)}
<option value="address">{translate("Sender domain")}</option>
<option value="size">{translate("Message size")}</option>
<option value="body">{translate("Body text")}</option>
<option value="true">{translate("Always (all messages)")}</option>
</select>
{customHeader && t.type === "header" && (
<input className="input" placeholder={translate("Header name")} aria-label={translate("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">{translate("is larger than")}</option><option value="under">{translate("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">{translate("contains")}</option><option value="notcontains">{translate("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}>{translate(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">{translate("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={translate("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} /> {translate("Add condition")}</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">{translate("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">{translate("Move to folder")}</option>
<option value="markread">{translate("Mark as read")}</option>
<option value="flag">{translate("Star")}</option>
<option value="addflag">{translate("Add label / keyword")}</option>
<option value="redirect">{translate("Forward to")}</option>
<option value="keep">{translate("Keep in Inbox")}</option>
<option value="discard">{translate("Delete")}</option>
<option value="reject">{translate("Reject with message")}</option>
<option value="stop">{translate("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: translate("New folder"), placeholder: translate("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(translate("Folder “{name}” created", { name: path }));
} 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__">{translate(" New folder…")}</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> {translate("keep copy")}</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder={translate("[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 })} /> {translate("keep copy")}</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder={translate("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={translate("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={translate("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} /> {translate("Add action")}</button>
</Dialog>
);
}