feat(webmail): pastas próprias na barra lateral e regras em linguagem comum
Regras. A tela pedia um tipo de correspondência do Sieve pelo nome - :contains, :is, :matches (regex) - e colava o valor escrito diretamente num script. Punha uma linguagem de padrões à frente de quem só queria dizer "o que vem do banco vai para Faturas", e bastava uma aspa no valor para o script deixar de fazer parse, desativando silenciosamente todas as regras de uma vez. O vocabulário passa a ser o de quem escreve: um campo, uma condição em português, um valor e uma ação. Os curingas são gerados a partir de "começa com" e "termina com" - ninguém os escreve, e um asterisco escrito por acaso continua a ser um asterisco. A pasta de destino é escolhida de uma lista das pastas que existem mesmo, em vez de ser escrita à mão: antes uma regra podia nomear uma pasta inexistente e nunca disparar, sem dizer porquê. A ordem passa a ser visível e alterável, porque a primeira regra que arquiva uma mensagem decide para onde ela vai. Um script que esta tela não consegue exprimir - escrito à mão, ou por um cliente de secretária sobre ManageSieve - é mostrado tal como está, em vez de ser reescrito na coisa mais parecida que este formulário sabe dizer, o que mudaria em silêncio o que o correio de alguém faz. Pastas próprias. Existiam na base de dados e as regras podiam arquivar nelas, mas a barra lateral listava só as seis fixas, portanto não havia como ver uma - nem como criar. Criar fica onde a vontade aparece, na própria barra; apagar avisa que leva as mensagens junto, porque leva. Encontrado a escrever isto: o motor tratava "\\*" como barra seguida de curinga em vez de um asterisco literal. Como é esta tela que gera os curingas, uma regra sobre um assunto com "50% *" teria correspondido a todos os assuntos começados por "50% ". Corrigido no motor, com testes dos dois lados. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42420a7de2
commit
d403c47a71
12 files changed
+828
-80
No files matched your search
@@ -11,6 +11,8 @@ import {
|
||||
Archive,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
Folder as FolderIcon,
|
||||
Plus,
|
||||
PenSquare,
|
||||
LogOut,
|
||||
Mail,
|
||||
@@ -24,7 +26,7 @@ import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { folderBadge } from "../../../lib/mailCounts";
|
||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -34,7 +36,31 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
const account = useAccount("user");
|
||||
// Live, not fetched once: these badges used to freeze at the value they had
|
||||
// when the tab was opened.
|
||||
const { folders } = useFolders();
|
||||
const { folders, reload: reloadFolders } = useFolders();
|
||||
const [newFolder, setNewFolder] = useState("");
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
|
||||
/** Creates, renames or deletes one of the user's own folders. */
|
||||
const manageFolder = async (body: Record<string, string>) => {
|
||||
setFolderError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/mail/folders/manage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setFolderError(data.message ?? t("errors.serverError"));
|
||||
return false;
|
||||
}
|
||||
reloadFolders();
|
||||
return true;
|
||||
} catch {
|
||||
setFolderError(t("errors.serverError"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
||||
@@ -137,6 +163,91 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The folders the user made. They exist in the database and the rules
|
||||
can file into them, but the sidebar listed only the fixed six, so
|
||||
there was no way to see one - or to make one. */}
|
||||
{customFolders(folders).length > 0 && <div className="lm-rule my-2.5" />}
|
||||
{customFolders(folders).map((folder) => {
|
||||
const badge = folderBadge(folders, folder.name);
|
||||
const href = `/user/mail/${encodeURIComponent(folder.name)}`;
|
||||
const isActive = decodeURIComponent(pathname) === decodeURIComponent(href);
|
||||
return (
|
||||
<div key={folder.name} className="group/folder relative">
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-active={isActive}
|
||||
title={folder.name}
|
||||
className="lm-nav"
|
||||
>
|
||||
<span className="lm-nav-mark" />
|
||||
<FolderIcon
|
||||
className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] leading-snug">{folder.name}</span>
|
||||
<span className="flex flex-none items-center gap-1.5 pr-5">
|
||||
{badge.total > 0 && (
|
||||
<span className="text-[11.5px] tabular-nums text-slate-500">{badge.total}</span>
|
||||
)}
|
||||
{badge.showsUnread && (
|
||||
<span className="rounded-full bg-indigo-500/20 px-1.5 py-px text-[11px] font-medium tabular-nums text-indigo-300">
|
||||
{badge.unread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{/* Deleting takes the mail inside with it, so the confirmation
|
||||
says so rather than asking a bare "are you sure". */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("folders.delete", { name: folder.name })}
|
||||
title={t("folders.delete", { name: folder.name })}
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
|
||||
void manageFolder({ action: "delete", name: folder.name });
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Creating one. Inline rather than behind a settings page: a folder is
|
||||
made at the moment the user realises they want one. */}
|
||||
<form
|
||||
className="mt-1.5 flex items-center gap-1.5 px-1"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const name = newFolder.trim();
|
||||
if (!name) return;
|
||||
if (await manageFolder({ action: "create", name })) setNewFolder("");
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
placeholder={t("folders.newPlaceholder")}
|
||||
aria-label={t("folders.new")}
|
||||
maxLength={100}
|
||||
className="min-w-0 flex-1 rounded-lg bg-dark-panel px-2.5 py-1.5 text-[12.5px] text-slate-200 shadow-edge placeholder:text-slate-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={t("folders.new")}
|
||||
title={t("folders.new")}
|
||||
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
{folderError && (
|
||||
<p role="alert" className="px-1 pt-1 text-[11.5px] leading-snug text-amber-300">
|
||||
{folderError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="lm-rule my-2.5" />
|
||||
|
||||
<Link
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Monitor,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Lock,
|
||||
Languages,
|
||||
@@ -20,6 +22,9 @@ import { Button } from "../../../../components/ui/Button";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
|
||||
import { useFolders } from "../../../../lib/useFolders";
|
||||
import { moveTargets } from "../../../../lib/mailCounts";
|
||||
|
||||
interface SieveRule {
|
||||
id: string;
|
||||
@@ -87,11 +92,17 @@ export default function UserSettingsPage() {
|
||||
// used to sit here, and because saving posts the whole list, the first time
|
||||
// anyone added or removed a rule that invented filter was written into their
|
||||
// real Sieve script and began moving their mail.
|
||||
const [rules, setRules] = useState<SieveRule[]>([]);
|
||||
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
|
||||
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
|
||||
const { folders } = useFolders();
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
// A script this screen cannot express - hand-written, or written by a
|
||||
// desktop client over ManageSieve. Shown as it is rather than rewritten into
|
||||
// the nearest thing this form can say, which would quietly change what the
|
||||
// user's mail does.
|
||||
const [foreignScript, setForeignScript] = useState<string | null>(null);
|
||||
const [newField, setNewField] = useState<RuleField>("subject");
|
||||
const [newCondition, setNewCondition] = useState<RuleCondition>("contains");
|
||||
const [newAction, setNewAction] = useState<RuleAction>("move");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newAction, setNewAction] = useState<"move" | "flag" | "discard" | "redirect">("move");
|
||||
const [newTarget, setNewTarget] = useState("Archive");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,10 +118,18 @@ export default function UserSettingsPage() {
|
||||
.catch(() => undefined);
|
||||
|
||||
fetch("/api/v1/user/sieve")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.script && data.script.includes("vacation")) {
|
||||
setVacationEnabled(data.is_active);
|
||||
const script = typeof data?.script === "string" ? data.script : "";
|
||||
const parsed = parseSieve(script);
|
||||
if (parsed === null) {
|
||||
// Not expressible in this form; shown verbatim instead of being
|
||||
// rewritten into something simpler that means something else.
|
||||
setForeignScript(script);
|
||||
setRules([]);
|
||||
} else {
|
||||
setForeignScript(null);
|
||||
setRules(parsed);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -146,44 +165,46 @@ export default function UserSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const addSieveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newValue) return;
|
||||
const updated = [
|
||||
...rules,
|
||||
{
|
||||
id: `rule-${Date.now()}`,
|
||||
field: newField,
|
||||
match: newMatch,
|
||||
value: newValue,
|
||||
action: newAction,
|
||||
targetFolder: newTarget,
|
||||
},
|
||||
];
|
||||
/** Writes the whole rule list to the server as one script. */
|
||||
const persistRules = async (updated: Rule[]) => {
|
||||
setRules(updated);
|
||||
setNewValue("");
|
||||
|
||||
const scriptLines = updated.map((r) =>
|
||||
`if header :${r.match} "${r.field}" "${r.value}" { fileinto "${r.targetFolder || "INBOX"}"; }`
|
||||
);
|
||||
await fetch("/api/v1/user/sieve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "user-rules", script: scriptLines.join("\n"), is_active: true }),
|
||||
body: JSON.stringify({ name: "user-rules", script: buildSieve(updated), is_active: true }),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const addSieveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = newValue.trim();
|
||||
if (!value) return;
|
||||
await persistRules([
|
||||
...rules,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
field: newField,
|
||||
condition: newCondition,
|
||||
value,
|
||||
action: newAction,
|
||||
target: newTarget,
|
||||
},
|
||||
]);
|
||||
setNewValue("");
|
||||
};
|
||||
|
||||
const removeSieveRule = async (id: string) => {
|
||||
const updated = rules.filter((r) => r.id !== id);
|
||||
setRules(updated);
|
||||
const scriptLines = updated.map((r) =>
|
||||
`if header :${r.match} "${r.field}" "${r.value}" { fileinto "${r.targetFolder || "INBOX"}"; }`
|
||||
);
|
||||
await fetch("/api/v1/user/sieve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "user-rules", script: scriptLines.join("\n"), is_active: true }),
|
||||
}).catch(() => undefined);
|
||||
await persistRules(rules.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
/** Moves a rule, since the first one that files a message decides. */
|
||||
const moveRule = async (index: number, delta: number) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rules.length) return;
|
||||
const reordered = [...rules];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
reordered.splice(target, 0, moved);
|
||||
await persistRules(reordered);
|
||||
};
|
||||
|
||||
const saveVacationResponder = (e: React.FormEvent) => {
|
||||
@@ -561,52 +582,62 @@ export default function UserSettingsPage() {
|
||||
{/* The rule reads as a sentence: IF <field> <match> <value> THEN
|
||||
<action>. Each control keeps its own label, so the row still
|
||||
makes sense when it wraps on a narrow window. */}
|
||||
{/* The rule reads as a sentence: IF <field> <condition>
|
||||
<value> THEN <action>. The vocabulary is deliberately the
|
||||
user's, not Sieve's: the old form offered ":matches (regex)"
|
||||
and pasted whatever was typed into a script, which asked
|
||||
someone who wanted "mail from the bank goes in Faturas" to
|
||||
write a pattern language. Wildcards are now generated from
|
||||
"starts with" and "ends with"; nobody types one. */}
|
||||
<form onSubmit={addSieveRule} className="flex flex-wrap items-end gap-2.5">
|
||||
<div className="pb-2.5 text-xs uppercase tracking-[0.08em] text-slate-400">
|
||||
{t("settings.ruleIf")}
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<label htmlFor="rule-field" className={fieldLabel}>
|
||||
{t("ui.eventAction")}
|
||||
{t("settings.ruleField")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-field"
|
||||
value={newField}
|
||||
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
|
||||
onChange={(e) => setNewField(e.target.value as RuleField)}
|
||||
className={input}
|
||||
>
|
||||
<option value="Subject">{t("settings.fieldSubject")}</option>
|
||||
<option value="From">{t("settings.fieldFrom")}</option>
|
||||
<option value="Header">{t("settings.fieldHeader")}</option>
|
||||
<option value="subject">{t("settings.fieldSubject")}</option>
|
||||
<option value="from">{t("settings.fieldFrom")}</option>
|
||||
<option value="to">{t("settings.fieldTo")}</option>
|
||||
<option value="cc">{t("settings.fieldCc")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<label htmlFor="rule-match" className={fieldLabel}>
|
||||
{t("common.status")}
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label htmlFor="rule-condition" className={fieldLabel}>
|
||||
{t("settings.ruleCondition")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-match"
|
||||
value={newMatch}
|
||||
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
|
||||
id="rule-condition"
|
||||
value={newCondition}
|
||||
onChange={(e) => setNewCondition(e.target.value as RuleCondition)}
|
||||
className={input}
|
||||
>
|
||||
<option value="contains">{t("settings.matchContains")}</option>
|
||||
<option value="equals">{t("settings.matchEquals")}</option>
|
||||
<option value="matches">{t("settings.matchRegex")}</option>
|
||||
<option value="contains">{t("settings.condContains")}</option>
|
||||
<option value="is">{t("settings.condIs")}</option>
|
||||
<option value="startsWith">{t("settings.condStartsWith")}</option>
|
||||
<option value="endsWith">{t("settings.condEndsWith")}</option>
|
||||
<option value="notContains">{t("settings.condNotContains")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[200px] flex-[2]">
|
||||
<label htmlFor="rule-value" className={fieldLabel}>
|
||||
{t("settings.valuePlaceholder")}
|
||||
{t("settings.ruleValue")}
|
||||
</label>
|
||||
<input
|
||||
id="rule-value"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder={t("settings.valuePlaceholder")}
|
||||
placeholder={t("settings.ruleValuePlaceholder")}
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
@@ -622,15 +653,39 @@ export default function UserSettingsPage() {
|
||||
<select
|
||||
id="rule-action"
|
||||
value={newAction}
|
||||
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
|
||||
onChange={(e) => setNewAction(e.target.value as RuleAction)}
|
||||
className={input}
|
||||
>
|
||||
<option value="move">{t("settings.actionMove")}</option>
|
||||
<option value="markRead">{t("settings.actionMarkRead")}</option>
|
||||
<option value="flag">{t("settings.actionFlag")}</option>
|
||||
<option value="discard">{t("settings.actionDiscard")}</option>
|
||||
<option value="delete">{t("settings.actionDiscard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Real folders, listed from the mailbox: the old form typed a
|
||||
folder name into the script by hand, so a rule could name a
|
||||
folder that did not exist and simply never fired. */}
|
||||
{newAction === "move" && (
|
||||
<div className="min-w-[170px] flex-1">
|
||||
<label htmlFor="rule-target" className={fieldLabel}>
|
||||
{t("mail.moveTo")}
|
||||
</label>
|
||||
<select
|
||||
id="rule-target"
|
||||
value={newTarget}
|
||||
onChange={(e) => setNewTarget(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
{moveTargets(folders, "").map((folder) => (
|
||||
<option key={folder.name} value={folder.name}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||
<Plus className="h-[15px] w-[15px]" />
|
||||
<span>{t("settings.addRule")}</span>
|
||||
@@ -638,18 +693,63 @@ export default function UserSettingsPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* A script this form cannot express is shown as it is. Rewriting
|
||||
somebody's rules into the nearest thing this vocabulary can say
|
||||
would quietly change what their mail does. */}
|
||||
{foreignScript !== null && (
|
||||
<section className={panel}>
|
||||
<h2 className={panelTitle}>{t("settings.foreignScriptTitle")}</h2>
|
||||
<p className={panelIntro}>{t("settings.foreignScriptIntro")}</p>
|
||||
<pre className="overflow-x-auto rounded-xl bg-dark-panel p-3.5 font-mono text-[12.5px] leading-relaxed text-slate-300 shadow-edge">
|
||||
{foreignScript}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{rules.length > 0 && (
|
||||
<section className={panel}>
|
||||
<p className={panelIntro}>{t("settings.ruleOrderNote")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rules.map((r, index) => (
|
||||
<div key={r.id} className={tile}>
|
||||
<span className="flex h-[22px] w-[22px] flex-none items-center justify-center rounded-[7px] bg-indigo-900 text-[11px] text-indigo-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-[200px] flex-1 break-words font-mono text-[13px] leading-relaxed text-slate-200">
|
||||
{t("settings.ruleIf")} <strong className="font-medium">{r.field}</strong> {r.match} "
|
||||
{r.value}" → {t("settings.ruleThen")} {r.action.toUpperCase()}
|
||||
{/* Read back as the sentence it was written as, not as
|
||||
the Sieve it became. */}
|
||||
<span className="min-w-[200px] flex-1 break-words text-[13px] leading-relaxed text-slate-200">
|
||||
{t("settings.ruleIf")}{" "}
|
||||
<strong className="font-medium">{t(`settings.field${r.field.charAt(0).toUpperCase()}${r.field.slice(1)}`)}</strong>{" "}
|
||||
{t(`settings.cond${r.condition.charAt(0).toUpperCase()}${r.condition.slice(1)}`).toLowerCase()}{" "}
|
||||
<strong className="font-medium">"{r.value}"</strong>
|
||||
{" \u2192 "}
|
||||
{r.action === "move"
|
||||
? `${t("mail.moveTo")} ${r.target}`
|
||||
: t(`settings.action${r.action === "delete" ? "Discard" : r.action.charAt(0).toUpperCase() + r.action.slice(1)}`)}
|
||||
</span>
|
||||
{/* Order matters: the first rule that files a message
|
||||
decides where it lands, so the list has to be
|
||||
rearrangeable. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void moveRule(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t("settings.ruleMoveUp")}
|
||||
title={t("settings.ruleMoveUp")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void moveRule(index, 1)}
|
||||
disabled={index === rules.length - 1}
|
||||
aria-label={t("settings.ruleMoveDown")}
|
||||
title={t("settings.ruleMoveDown")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSieveRule(r.id)}
|
||||
|
||||
@@ -222,3 +222,26 @@ export function moveTargets(
|
||||
return Boolean(folder.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders the user created for themselves.
|
||||
*
|
||||
* The sidebar lists the standard folders from a fixed array, so anything the
|
||||
* user made appears nowhere unless it is picked out separately - which is why
|
||||
* a rule could name a folder that had no way of being seen or created.
|
||||
*
|
||||
* A custom folder is one with no special-use role, minus the folders this
|
||||
* server creates for its own purposes: Reports is filled by the report
|
||||
* ingestion and is not the user's to rename or delete.
|
||||
*/
|
||||
const SYSTEM_OWNED = new Set(["reports", "inbox", "sent", "drafts", "trash", "junk", "archive"]);
|
||||
|
||||
export function customFolders(folders: FolderSummary[] | undefined | null): FolderSummary[] {
|
||||
return (folders ?? [])
|
||||
.filter((folder) => {
|
||||
const role = (folder.special_use ?? "").trim();
|
||||
const name = (folder.name ?? "").trim().toLowerCase();
|
||||
return role === "" && name !== "" && !SYSTEM_OWNED.has(name);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Rules as a person describes them, and the Sieve the server runs.
|
||||
*
|
||||
* The old screen asked which Sieve match type to use - :contains, :is,
|
||||
* :matches - and pasted the typed value straight into a script. That put a
|
||||
* pattern language in front of someone whose actual thought was "mail from the
|
||||
* bank goes in Faturas", and it produced a script that did not parse the
|
||||
* moment a value contained a quote, which silently disabled every rule at once.
|
||||
*
|
||||
* So the vocabulary here is the user's: a field, a plain-language condition, a
|
||||
* value and an action. Wildcards are generated from "starts with" and "ends
|
||||
* with"; nobody types one, and one typed by accident stays a literal.
|
||||
*/
|
||||
|
||||
export type RuleField = "from" | "to" | "cc" | "subject";
|
||||
export type RuleCondition = "contains" | "is" | "startsWith" | "endsWith" | "notContains";
|
||||
export type RuleAction = "move" | "delete" | "markRead" | "flag";
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
field: RuleField;
|
||||
condition: RuleCondition;
|
||||
value: string;
|
||||
action: RuleAction;
|
||||
/** The destination folder, for the "move" action. */
|
||||
target: string;
|
||||
}
|
||||
|
||||
const HEADERS: Record<RuleField, string> = {
|
||||
from: "From",
|
||||
to: "To",
|
||||
cc: "Cc",
|
||||
subject: "Subject",
|
||||
};
|
||||
|
||||
/**
|
||||
* Escapes a value for a Sieve string.
|
||||
*
|
||||
* Backslash first, or the backslashes added for the quotes would themselves be
|
||||
* escaped a second time. A value carrying a single quote used to end the
|
||||
* string early and break the whole script.
|
||||
*/
|
||||
function quote(value: string): string {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the characters Sieve's :matches treats as wildcards.
|
||||
*
|
||||
* Needed because the wildcard is ours, not the user's: someone writing
|
||||
* "50% * desconto" means an asterisk, and it must not become "any run of
|
||||
* characters" just because the surrounding condition happens to use :matches.
|
||||
*/
|
||||
function escapeWildcards(value: string): string {
|
||||
return value.replace(/([*?\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
/** The Sieve test for one rule, without the surrounding if. */
|
||||
function testFor(rule: Rule): string {
|
||||
const header = quote(HEADERS[rule.field] ?? "Subject");
|
||||
|
||||
switch (rule.condition) {
|
||||
case "is":
|
||||
return `header :is ${header} ${quote(rule.value)}`;
|
||||
case "startsWith":
|
||||
return `header :matches ${header} ${quote(escapeWildcards(rule.value) + "*")}`;
|
||||
case "endsWith":
|
||||
return `header :matches ${header} ${quote("*" + escapeWildcards(rule.value))}`;
|
||||
case "notContains":
|
||||
return `not header :contains ${header} ${quote(rule.value)}`;
|
||||
default:
|
||||
return `header :contains ${header} ${quote(rule.value)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function actionFor(rule: Rule): string {
|
||||
switch (rule.action) {
|
||||
case "delete":
|
||||
return "discard;";
|
||||
case "markRead":
|
||||
return `addflag "\\\\Seen";`;
|
||||
case "flag":
|
||||
return `addflag "\\\\Flagged";`;
|
||||
default:
|
||||
return `fileinto ${quote(rule.target || "INBOX")};`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The script for a list of rules, in the order they are listed. */
|
||||
export function buildSieve(rules: Rule[]): string {
|
||||
return rules
|
||||
.filter((rule) => rule.value.trim() !== "")
|
||||
.map((rule) => `if ${testFor(rule)} { ${actionFor(rule)} }`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// --- reading a script back -----------------------------------------------
|
||||
|
||||
/** One rule as this screen writes it, and nothing else. */
|
||||
const RULE_LINE =
|
||||
/^if (not )?header :(contains|is|matches) "([^"]+)" "((?:[^"\\]|\\.)*)" \{ (fileinto "((?:[^"\\]|\\.)*)"|discard|addflag "\\\\(Seen|Flagged)");? \}$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
return value.replace(/\\(["\\])/g, "$1");
|
||||
}
|
||||
|
||||
function unescapeWildcards(value: string): string {
|
||||
return value.replace(/\\([*?\\])/g, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a script back into rules, or returns null when it cannot.
|
||||
*
|
||||
* Null means "this screen cannot represent this script" - a hand-written one,
|
||||
* or something a desktop client wrote over ManageSieve. The screen then shows
|
||||
* the script rather than a simplified version of it, because rewriting
|
||||
* somebody's rules into the nearest thing this form can express would quietly
|
||||
* change what their mail does.
|
||||
*/
|
||||
export function parseSieve(script: string): Rule[] | null {
|
||||
const source = (script ?? "").trim();
|
||||
if (source === "") return [];
|
||||
|
||||
const rules: Rule[] = [];
|
||||
const lines = source.split("\n").map((l) => l.trim()).filter((l) => l !== "");
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const match = RULE_LINE.exec(line);
|
||||
if (!match) return null;
|
||||
|
||||
const [, negated, comparator, header, rawValue, , rawFolder, flag] = match;
|
||||
const field = (Object.keys(HEADERS) as RuleField[]).find(
|
||||
(key) => HEADERS[key].toLowerCase() === header.toLowerCase(),
|
||||
);
|
||||
if (!field) return null;
|
||||
|
||||
let value = unquote(rawValue);
|
||||
let condition: RuleCondition;
|
||||
if (negated) {
|
||||
condition = "notContains";
|
||||
} else if (comparator === "is") {
|
||||
condition = "is";
|
||||
} else if (comparator === "matches") {
|
||||
// Which end the generated wildcard sits on is what says whether the user
|
||||
// chose "starts with" or "ends with".
|
||||
if (value.endsWith("*") && !value.endsWith("\\*")) {
|
||||
condition = "startsWith";
|
||||
value = unescapeWildcards(value.slice(0, -1));
|
||||
} else if (value.startsWith("*")) {
|
||||
condition = "endsWith";
|
||||
value = unescapeWildcards(value.slice(1));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
condition = "contains";
|
||||
}
|
||||
|
||||
let action: RuleAction = "move";
|
||||
let target = "";
|
||||
if (rawFolder !== undefined) {
|
||||
target = unquote(rawFolder);
|
||||
} else if (flag === "Seen") {
|
||||
action = "markRead";
|
||||
} else if (flag === "Flagged") {
|
||||
action = "flag";
|
||||
} else {
|
||||
action = "delete";
|
||||
}
|
||||
|
||||
rules.push({ id: String(index + 1), field, condition, value, action, target });
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
@@ -38,9 +38,16 @@ describe("the counters the sidebar shows", () => {
|
||||
|
||||
// The total was rendered twice for Drafts, where the badge and the size are
|
||||
// the same number: "Rascunhos 1 1".
|
||||
it("renders the folder size in exactly one place", () => {
|
||||
//
|
||||
// Two occurrences now, not one: the standard folders and the folders the
|
||||
// user created are rendered by separate templates. What must not happen is
|
||||
// one template printing it twice, which is what the defect was.
|
||||
it("renders the folder size once per folder template", () => {
|
||||
const layout = code(USER_LAYOUT);
|
||||
expect(layout.match(/\{badge\.total\}/g) ?? []).toHaveLength(1);
|
||||
expect(layout.match(/\{badge\.total\}/g) ?? []).toHaveLength(2);
|
||||
// And each template guards the unread badge, so the two numbers can never
|
||||
// be the same value shown twice.
|
||||
expect(layout.match(/badge\.showsUnread/g) ?? []).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The folder list was fetched once on mount, so every badge froze at the
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("marking a message read in the loaded list", () => {
|
||||
|
||||
// --- what the sidebar actually renders -----------------------------------
|
||||
|
||||
import { folderBadge, readerActions, moveTargets } from "./lib/mailCounts";
|
||||
import { folderBadge, readerActions, moveTargets, customFolders } from "./lib/mailCounts";
|
||||
|
||||
/**
|
||||
* These reproduce what the running webmail shows, which the badgeCount tests
|
||||
@@ -302,3 +302,34 @@ describe("where a message may be moved", () => {
|
||||
expect(moveTargets(folders, "sent").map((f) => f.name)).toContain("Archive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a user made themselves", () => {
|
||||
const folders = [
|
||||
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 3 },
|
||||
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 1 },
|
||||
{ special_use: "trash", name: "Trash", unread_count: 0, total_count: 0 },
|
||||
{ special_use: "", name: "Reports", unread_count: 0, total_count: 2 },
|
||||
{ special_use: "", name: "Faturas", unread_count: 1, total_count: 4 },
|
||||
{ special_use: "", name: "Clientes", unread_count: 0, total_count: 0 },
|
||||
];
|
||||
|
||||
// The sidebar lists the standard folders from a fixed array, so a folder the
|
||||
// user created appears nowhere unless it is picked out separately.
|
||||
it("are the ones with no special-use role, minus the system's own", () => {
|
||||
const names = customFolders(folders).map((f) => f.name);
|
||||
expect(names).toEqual(["Clientes", "Faturas"]);
|
||||
});
|
||||
|
||||
it("leaves out Reports, which this server fills itself", () => {
|
||||
expect(customFolders(folders).map((f) => f.name)).not.toContain("Reports");
|
||||
});
|
||||
|
||||
it("lists them in alphabetical order, since nothing else orders them", () => {
|
||||
expect(customFolders(folders).map((f) => f.name)).toEqual(["Clientes", "Faturas"]);
|
||||
});
|
||||
|
||||
it("copes with no folders at all", () => {
|
||||
expect(customFolders([])).toEqual([]);
|
||||
expect(customFolders(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSieve, parseSieve, type Rule } from "./lib/rules";
|
||||
|
||||
/**
|
||||
* Rules, as a person describes them, turned into Sieve and back.
|
||||
*
|
||||
* The old screen asked for a Sieve match type by name - :contains, :is,
|
||||
* :matches - and then pasted the value straight into a script. That put a
|
||||
* pattern language in front of someone who wanted to say "mail from the bank
|
||||
* goes in Faturas", and it broke outright on a value containing a quote.
|
||||
*/
|
||||
|
||||
const rule = (over: Partial<Rule> = {}): Rule => ({
|
||||
id: "1",
|
||||
field: "subject",
|
||||
condition: "contains",
|
||||
value: "Fatura",
|
||||
action: "move",
|
||||
target: "Faturas",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("turning a rule into Sieve", () => {
|
||||
it("writes a filing rule the engine understands", () => {
|
||||
const script = buildSieve([rule()]);
|
||||
expect(script).toContain('if header :contains "Subject" "Fatura"');
|
||||
expect(script).toContain('fileinto "Faturas"');
|
||||
});
|
||||
|
||||
// The wildcard is generated, never typed. "Starts with" is a thing a person
|
||||
// can mean; "^Fatura" is not.
|
||||
it("expresses starts-with as a wildcard the user never sees", () => {
|
||||
const script = buildSieve([rule({ condition: "startsWith", value: "Fatura" })]);
|
||||
expect(script).toContain(':matches "Subject" "Fatura*"');
|
||||
});
|
||||
|
||||
it("expresses ends-with the same way", () => {
|
||||
const script = buildSieve([rule({ condition: "endsWith", value: ".pdf" })]);
|
||||
expect(script).toContain(':matches "Subject" "*.pdf"');
|
||||
});
|
||||
|
||||
it("expresses is-exactly without wildcards", () => {
|
||||
const script = buildSieve([rule({ condition: "is", value: "Fatura" })]);
|
||||
expect(script).toContain(':is "Subject" "Fatura"');
|
||||
});
|
||||
|
||||
it("expresses does-not-contain as a negated test", () => {
|
||||
const script = buildSieve([rule({ condition: "notContains", value: "spam" })]);
|
||||
expect(script).toContain('if not header :contains "Subject" "spam"');
|
||||
});
|
||||
|
||||
// A value with a quote in it used to end the Sieve string early and produce
|
||||
// a script that did not parse - so one apostrophe silently disabled every
|
||||
// rule the user had.
|
||||
it("escapes a quote in the value", () => {
|
||||
const script = buildSieve([rule({ value: 'ele disse "olá"' })]);
|
||||
expect(script).toContain('\\"olá\\"');
|
||||
expect(parseSieve(script)!).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("escapes a backslash", () => {
|
||||
const script = buildSieve([rule({ value: "a\\b" })]);
|
||||
expect(parseSieve(script)![0].value).toBe("a\\b");
|
||||
});
|
||||
|
||||
// A wildcard typed into a "contains" rule is a literal, not a pattern: the
|
||||
// user meant an asterisk.
|
||||
it("does not let a typed asterisk become a wildcard", () => {
|
||||
const script = buildSieve([rule({ condition: "contains", value: "50% * desconto" })]);
|
||||
expect(script).toContain(":contains");
|
||||
expect(script).not.toContain(":matches");
|
||||
});
|
||||
|
||||
it("writes the other actions", () => {
|
||||
expect(buildSieve([rule({ action: "delete" })])).toContain("discard;");
|
||||
expect(buildSieve([rule({ action: "markRead" })])).toContain('addflag "\\\\Seen"');
|
||||
expect(buildSieve([rule({ action: "flag" })])).toContain('addflag "\\\\Flagged"');
|
||||
});
|
||||
|
||||
it("maps every field to its real header", () => {
|
||||
expect(buildSieve([rule({ field: "from" })])).toContain('"From"');
|
||||
expect(buildSieve([rule({ field: "to" })])).toContain('"To"');
|
||||
expect(buildSieve([rule({ field: "cc" })])).toContain('"Cc"');
|
||||
expect(buildSieve([rule({ field: "subject" })])).toContain('"Subject"');
|
||||
});
|
||||
|
||||
it("writes several rules in the order they are listed", () => {
|
||||
const script = buildSieve([
|
||||
rule({ id: "1", value: "Fatura", target: "Faturas" }),
|
||||
rule({ id: "2", value: "Convite", target: "Eventos" }),
|
||||
]);
|
||||
expect(script.indexOf("Faturas")).toBeLessThan(script.indexOf("Eventos"));
|
||||
});
|
||||
|
||||
it("produces an empty script for no rules", () => {
|
||||
expect(buildSieve([]).trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading rules back out of Sieve", () => {
|
||||
it("round-trips every condition", () => {
|
||||
const conditions: Rule["condition"][] = ["contains", "is", "startsWith", "endsWith", "notContains"];
|
||||
for (const condition of conditions) {
|
||||
const original = rule({ condition, value: "Fatura" });
|
||||
const [read] = parseSieve(buildSieve([original]))!;
|
||||
expect(read.condition, condition).toBe(condition);
|
||||
expect(read.value, condition).toBe("Fatura");
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips every action", () => {
|
||||
for (const action of ["move", "delete", "markRead", "flag"] as Rule["action"][]) {
|
||||
const [read] = parseSieve(buildSieve([rule({ action })]))!;
|
||||
expect(read.action, action).toBe(action);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips the field and the target folder", () => {
|
||||
const [read] = parseSieve(buildSieve([rule({ field: "from", target: "Relatórios" })]))!;
|
||||
expect(read.field).toBe("from");
|
||||
expect(read.target).toBe("Relatórios");
|
||||
});
|
||||
|
||||
// A script written by hand, or by a desktop client over ManageSieve, may use
|
||||
// things this screen cannot express. It must be left alone rather than
|
||||
// silently rewritten into something simpler.
|
||||
it("reports a script it cannot represent instead of mangling it", () => {
|
||||
expect(parseSieve(`if anyof (header :contains "Subject" "a", header :is "From" "b") { fileinto "X"; }`))
|
||||
.toBeNull();
|
||||
expect(parseSieve(`require ["vacation"];\nvacation "fora";`)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads an empty script as no rules", () => {
|
||||
expect(parseSieve("")).toEqual([]);
|
||||
expect(parseSieve(" \n ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an asterisk the user typed", () => {
|
||||
// The dangerous case is not "contains" - it is a value containing an
|
||||
// asterisk in a condition that really does use :matches. There the typed
|
||||
// character sits in the same string as the generated wildcard, and if it is
|
||||
// not escaped it silently becomes "any run of characters": a rule meant for
|
||||
// subjects starting with "50% *" would match every subject starting with
|
||||
// "50% ".
|
||||
it("stays literal in starts-with", () => {
|
||||
const script = buildSieve([rule({ condition: "startsWith", value: "50% * desconto" })]);
|
||||
expect(script).toContain("50% \\\\* desconto*");
|
||||
});
|
||||
|
||||
it("stays literal in ends-with", () => {
|
||||
const script = buildSieve([rule({ condition: "endsWith", value: "* fim" })]);
|
||||
expect(script).toContain("*\\\\* fim");
|
||||
});
|
||||
|
||||
it("survives the round trip unchanged", () => {
|
||||
for (const condition of ["startsWith", "endsWith"] as Rule["condition"][]) {
|
||||
const [read] = parseSieve(buildSieve([rule({ condition, value: "50% * desconto" })]))!;
|
||||
expect(read.value, condition).toBe("50% * desconto");
|
||||
expect(read.condition, condition).toBe(condition);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a question mark literal too", () => {
|
||||
const [read] = parseSieve(buildSieve([rule({ condition: "startsWith", value: "Onde?" })]))!;
|
||||
expect(read.value).toBe("Onde?");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user