From d403c47a714ff8797b109b16a305df9fec4cf100 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 22 Aug 2026 13:48:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(webmail):=20pastas=20pr=C3=B3prias=20na=20?= =?UTF-8?q?barra=20lateral=20e=20regras=20em=20linguagem=20comum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/domain/sieve/sieve_test.go | 37 +++ .../protocols/internal/domain/sieve/test.go | 53 +++-- services/webmail/messages/en.json | 25 +- services/webmail/messages/es.json | 25 +- services/webmail/messages/pt-BR.json | 25 +- .../webmail/src/app/(user)/user/layout.tsx | 115 ++++++++- .../src/app/(user)/user/settings/page.tsx | 218 +++++++++++++----- services/webmail/src/lib/mailCounts.ts | 23 ++ services/webmail/src/lib/rules.ts | 175 ++++++++++++++ .../webmail/src/mail-state-integrity.test.ts | 11 +- services/webmail/src/mailCounts.test.ts | 33 ++- services/webmail/src/rules.test.ts | 168 ++++++++++++++ 12 files changed, 828 insertions(+), 80 deletions(-) create mode 100644 services/webmail/src/lib/rules.ts create mode 100644 services/webmail/src/rules.test.ts diff --git a/services/protocols/internal/domain/sieve/sieve_test.go b/services/protocols/internal/domain/sieve/sieve_test.go index ef51803..26208ca 100644 --- a/services/protocols/internal/domain/sieve/sieve_test.go +++ b/services/protocols/internal/domain/sieve/sieve_test.go @@ -355,3 +355,40 @@ func TestMatchesDoesNotBlowUpOnManyStars(t *testing.T) { t.Fatal("wildcard matching took more than three seconds; it is backtracking exponentially") } } + +// A backslash escapes a wildcard, per RFC 5228 section 2.7.1: "\*" is a real +// asterisk, not "any run of characters". +// +// This matters because the rules screen generates the wildcards itself. When +// someone writes a rule about a subject containing "50% *", the screen escapes +// their asterisk so it stays literal - and if this engine ignored the escape, +// that rule would match every subject starting with "50% " instead. +func TestEscapedWildcardIsALiteralCharacter(t *testing.T) { + script := `if header :matches "Subject" "50% \\* desconto*" { fileinto "X"; }` + + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"50% * desconto de agosto"}, + }}); out.Folder != "X" { + t.Errorf("the literal asterisk did not match: %q", out.Folder) + } + // The escaped asterisk must not behave as a wildcard. + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"50% enorme desconto de agosto"}, + }}); out.Folder != "" { + t.Errorf("the escaped asterisk was treated as a wildcard: %q", out.Folder) + } +} + +func TestEscapedQuestionMarkIsALiteralCharacter(t *testing.T) { + script := `if header :matches "Subject" "Onde\\?" { fileinto "X"; }` + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"Onde?"}, + }}); out.Folder != "X" { + t.Errorf("the literal question mark did not match: %q", out.Folder) + } + if out := evaluate(t, script, Message{Headers: map[string][]string{ + "subject": {"OndeX"}, + }}); out.Folder != "" { + t.Errorf("the escaped question mark matched any character: %q", out.Folder) + } +} diff --git a/services/protocols/internal/domain/sieve/test.go b/services/protocols/internal/domain/sieve/test.go index fa803ff..d925bbb 100644 --- a/services/protocols/internal/domain/sieve/test.go +++ b/services/protocols/internal/domain/sieve/test.go @@ -80,29 +80,56 @@ func compare(comparator, actual, want string) bool { // that take exponential time on a crafted subject line. func wildcardMatch(value, pattern string) bool { v, p := []rune(value), []rune(pattern) + + // literalAt reports whether the pattern rune at i is escaped, and how wide + // the escape is. RFC 5228 section 2.7.1: a backslash makes the next + // character literal, which is how a rule can be about a real asterisk. + // The rules screen relies on this - it escapes any wildcard the user typed + // so it stays the character they meant. + escaped := func(i int) (rune, int, bool) { + if p[i] == '\\' && i+1 < len(p) { + return p[i+1], 2, true + } + return p[i], 1, false + } + // Iterative backtracking: linear in the common case, and it cannot blow // the stack on a long subject the way a recursive version can. vi, pi := 0, 0 star, mark := -1, 0 for vi < len(v) { - switch { - case pi < len(p) && (p[pi] == '?' || p[pi] == v[vi]): - vi++ - pi++ - case pi < len(p) && p[pi] == '*': - star = pi - mark = vi - pi++ - case star >= 0: + if pi < len(p) { + ch, width, isEscaped := escaped(pi) + switch { + case isEscaped && ch == v[vi]: + vi++ + pi += width + continue + case !isEscaped && (ch == '?' || ch == v[vi]): + vi++ + pi += width + continue + case !isEscaped && ch == '*': + star = pi + mark = vi + pi += width + continue + } + } + if star >= 0 { pi = star + 1 mark++ vi = mark - default: - return false + continue } + return false } - for pi < len(p) && p[pi] == '*' { - pi++ + for pi < len(p) { + ch, width, isEscaped := escaped(pi) + if isEscaped || ch != '*' { + break + } + pi += width } return pi == len(p) } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 36c9bf4..99f1c2d 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -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." } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 5f42b1d..fc3c623 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -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." } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index f698046..9462c19 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -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." } } diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index d7fa85e..14016fd 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -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(null); + + /** Creates, renames or deletes one of the user's own folders. */ + const manageFolder = async (body: Record) => { + 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 &&
} + {customFolders(folders).map((folder) => { + const badge = folderBadge(folders, folder.name); + const href = `/user/mail/${encodeURIComponent(folder.name)}`; + const isActive = decodeURIComponent(pathname) === decodeURIComponent(href); + return ( +
+ setMobileOpen(false)} + data-active={isActive} + title={folder.name} + className="lm-nav" + > + + + {folder.name} + + {badge.total > 0 && ( + {badge.total} + )} + {badge.showsUnread && ( + + {badge.unread} + + )} + + + {/* Deleting takes the mail inside with it, so the confirmation + says so rather than asking a bare "are you sure". */} + +
+ ); + })} + + {/* Creating one. Inline rather than behind a settings page: a folder is + made at the moment the user realises they want one. */} +
{ + e.preventDefault(); + const name = newFolder.trim(); + if (!name) return; + if (await manageFolder({ action: "create", name })) setNewFolder(""); + }} + > + 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" + /> + +
+ {folderError && ( +

+ {folderError} +

+ )} +
([]); - const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject"); - const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains"); + const { folders } = useFolders(); + const [rules, setRules] = useState([]); + // 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(null); + const [newField, setNewField] = useState("subject"); + const [newCondition, setNewCondition] = useState("contains"); + const [newAction, setNewAction] = useState("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 THEN . 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 + THEN . 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. */}
{t("settings.ruleIf")}
-
+
-
-