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:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:48:10 -03:00
1 parent 42420a7de2
commit d403c47a71
12 files changed
+828 -80

No files matched your search

+113 -2
View File
@@ -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} &quot;
{r.value}&quot; &rarr; {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">&quot;{r.value}&quot;</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)}