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
@@ -257,7 +257,24 @@
|
||||
"vacationIntro": "Reply automatically to incoming mail while you are away.",
|
||||
"vacationSubjectLabel": "Reply subject",
|
||||
"vacationTitle": "Out-of-office auto-reply",
|
||||
"valuePlaceholder": "Value to match..."
|
||||
"valuePlaceholder": "Value to match...",
|
||||
"ruleField": "Field",
|
||||
"ruleCondition": "Condition",
|
||||
"ruleValue": "Value",
|
||||
"ruleValuePlaceholder": "e.g. invoice",
|
||||
"fieldTo": "To",
|
||||
"fieldCc": "Cc",
|
||||
"condContains": "Contains",
|
||||
"condIs": "Is exactly",
|
||||
"condStartsWith": "Starts with",
|
||||
"condEndsWith": "Ends with",
|
||||
"condNotContains": "Does not contain",
|
||||
"actionMarkRead": "Mark as read",
|
||||
"ruleMoveUp": "Move up",
|
||||
"ruleMoveDown": "Move down",
|
||||
"ruleOrderNote": "Rules are applied in order, and the first one that files a message decides where it goes.",
|
||||
"foreignScriptTitle": "Rules written elsewhere",
|
||||
"foreignScriptIntro": "These rules were written by hand or by a desktop client and use features this screen cannot show. They are still applied to your mail. Editing them here would change what they do, so they are shown as they are."
|
||||
},
|
||||
"ui": {
|
||||
"accountLocked": "Locked",
|
||||
@@ -337,5 +354,11 @@
|
||||
"underline": "Underline",
|
||||
"insertLink": "Link",
|
||||
"insertImage": "Image"
|
||||
},
|
||||
"folders": {
|
||||
"new": "New folder",
|
||||
"newPlaceholder": "New folder…",
|
||||
"delete": "Delete {name}",
|
||||
"deleteConfirm": "Delete the folder \"{name}\" and every message in it? This cannot be undone from the interface."
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,24 @@
|
||||
"vacationIntro": "Responde automáticamente al correo entrante mientras esté ausente.",
|
||||
"vacationSubjectLabel": "Asunto de la respuesta",
|
||||
"vacationTitle": "Respuesta automática de ausencia",
|
||||
"valuePlaceholder": "Valor a coincidir..."
|
||||
"valuePlaceholder": "Valor a coincidir...",
|
||||
"ruleField": "Campo",
|
||||
"ruleCondition": "Condición",
|
||||
"ruleValue": "Valor",
|
||||
"ruleValuePlaceholder": "p. ej. factura",
|
||||
"fieldTo": "Para",
|
||||
"fieldCc": "Cc",
|
||||
"condContains": "Contiene",
|
||||
"condIs": "Es exactamente",
|
||||
"condStartsWith": "Empieza por",
|
||||
"condEndsWith": "Termina en",
|
||||
"condNotContains": "No contiene",
|
||||
"actionMarkRead": "Marcar como leída",
|
||||
"ruleMoveUp": "Subir",
|
||||
"ruleMoveDown": "Bajar",
|
||||
"ruleOrderNote": "Las reglas se aplican en orden, y la primera que archiva un mensaje decide dónde va.",
|
||||
"foreignScriptTitle": "Reglas escritas en otro sitio",
|
||||
"foreignScriptIntro": "Estas reglas se escribieron a mano o desde un cliente de correo y usan funciones que esta pantalla no puede mostrar. Se siguen aplicando. Editarlas aquí cambiaría lo que hacen, por eso se muestran tal cual."
|
||||
},
|
||||
"ui": {
|
||||
"accountLocked": "Bloqueada",
|
||||
@@ -337,5 +354,11 @@
|
||||
"underline": "Subrayado",
|
||||
"insertLink": "Enlace",
|
||||
"insertImage": "Imagen"
|
||||
},
|
||||
"folders": {
|
||||
"new": "Nueva carpeta",
|
||||
"newPlaceholder": "Nueva carpeta…",
|
||||
"delete": "Eliminar {name}",
|
||||
"deleteConfirm": "¿Eliminar la carpeta \"{name}\" y todos sus mensajes? Esto no se puede deshacer desde la interfaz."
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,24 @@
|
||||
"vacationIntro": "Responde automaticamente às mensagens recebidas enquanto você estiver ausente.",
|
||||
"vacationSubjectLabel": "Assunto da resposta",
|
||||
"vacationTitle": "Resposta automática de ausência",
|
||||
"valuePlaceholder": "Valor a corresponder..."
|
||||
"valuePlaceholder": "Valor a corresponder...",
|
||||
"ruleField": "Campo",
|
||||
"ruleCondition": "Condição",
|
||||
"ruleValue": "Valor",
|
||||
"ruleValuePlaceholder": "ex.: fatura",
|
||||
"fieldTo": "Para",
|
||||
"fieldCc": "Cc",
|
||||
"condContains": "Contém",
|
||||
"condIs": "É exatamente",
|
||||
"condStartsWith": "Começa com",
|
||||
"condEndsWith": "Termina com",
|
||||
"condNotContains": "Não contém",
|
||||
"actionMarkRead": "Marcar como lida",
|
||||
"ruleMoveUp": "Mover para cima",
|
||||
"ruleMoveDown": "Mover para baixo",
|
||||
"ruleOrderNote": "As regras são aplicadas em ordem, e a primeira que arquiva uma mensagem decide onde ela fica.",
|
||||
"foreignScriptTitle": "Regras escritas noutro sítio",
|
||||
"foreignScriptIntro": "Estas regras foram escritas à mão ou por um cliente de e-mail e usam recursos que esta tela não consegue mostrar. Continuam a ser aplicadas ao seu correio. Editá-las aqui mudaria o que fazem, por isso são mostradas como estão."
|
||||
},
|
||||
"ui": {
|
||||
"accountLocked": "Bloqueada",
|
||||
@@ -337,5 +354,11 @@
|
||||
"underline": "Sublinhado",
|
||||
"insertLink": "Link",
|
||||
"insertImage": "Imagem"
|
||||
},
|
||||
"folders": {
|
||||
"new": "Nova pasta",
|
||||
"newPlaceholder": "Nova pasta…",
|
||||
"delete": "Apagar {name}",
|
||||
"deleteConfirm": "Apagar a pasta \"{name}\" e todas as mensagens nela? Isto não pode ser desfeito pela interface."
|
||||
}
|
||||
}
|
||||
@@ -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