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