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

+23
View File
@@ -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));
}
+175
View File
@@ -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;
}