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")}
-
+
-
-