fix(webmail): acertos de interface e mover mensagens entre pastas

Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês
que restava, e "Bcc" tem nome próprio em português - quem não conhece
a abreviatura inglesa não adivinha o que o campo faz.

Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console
de administração", que transbordava no telemóvel e no computador: o
botão passa a mostrar um rótulo curto e guarda o nome completo no
tooltip. O endereço da conta usava break-words e partia-se a meio do
domínio, deixando um "br" sozinho na linha de baixo; passa a truncar
com o valor inteiro no tooltip.

A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã
largo os assuntos eram espremidos numa coluna estreita enquanto o
leitor ficava entre duas margens enormes. Ambos crescem agora com a
janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do
Google são uma única "palavra" sem espaços, mais longa que a coluna, e
sem sítio onde quebrar saíam da linha.

Imagens remotas. A escolha era reposta sempre que uma mensagem era
aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada
leitura. A decisão passa a ser lembrada por remetente, com um segundo
botão para a retirar. Bloqueado continua a ser o que acontece por
omissão: uma imagem remota é um aviso de leitura que o remetente não
pediu licença para receber.

Assinatura. Era escrita num textarea - texto simples com quebras de
linha reais - e injetada no compositor com innerHTML, onde uma quebra
de linha é espaço em branco: todas desapareciam. Passa a ser um campo
rico, com negrito, itálico, links e imagens, guardado como HTML e
sanitizado nos dois sentidos, já que este valor é injetado como
marcação na origem do webmail. Assinaturas guardadas pelo campo antigo
continuam a funcionar: as suas quebras de linha são convertidas.

Mover entre pastas. Faltava a única ação corrente que o leitor não
tinha: arquivar uma mensagem para fora da caixa de entrada era
impossível. Enviados e Rascunhos nunca são destino - descrevem como a
mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é
permitido. Pastas criadas pelo utilizador, que não têm papel
special-use, são destino como qualquer outra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:18:08 -03:00
1 parent ac0521cb9b
commit f179cb1f27
20 files changed
+911 -37

No files matched your search

@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { signatureToHtml } from "../../../../lib/signature";
/** Splits a comma or semicolon separated field, dropping empty entries. */
function splitAddresses(value: string): string[] {
@@ -69,8 +70,12 @@ export default function ComposePage({
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
if (editorRef.current && savedSig) {
const initial = `<br/><br/>--<br/>${savedSig}`;
// signatureToHtml, not the raw value: the signature may be the plain text
// of the old field, whose newlines are whitespace once assigned as HTML -
// which is why every line break in a signature used to vanish here.
const signatureHtml = signatureToHtml(savedSig ?? "");
if (editorRef.current && signatureHtml) {
const initial = `<br/><br/>--<br/>${signatureHtml}`;
editorRef.current.innerHTML = initial;
setBody(initial);
}
@@ -322,7 +327,7 @@ export default function ComposePage({
<>
<div className={fieldRow}>
<label htmlFor="compose-cc" className={fieldLabel}>
Cc
{t("mail.ccLabel")}
</label>
<input
id="compose-cc"
@@ -336,7 +341,7 @@ export default function ComposePage({
</div>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
Bcc
{t("mail.bccLabel")}
</label>
<input
id="compose-bcc"
@@ -167,10 +167,12 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</span>
<Link
href="/admin/step-up"
title={t("admin.openAdmin")}
aria-label={t("admin.openAdmin")}
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
>
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.openAdmin")}</span>
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
</Link>
</div>
)}
@@ -184,7 +186,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{initialsFor(account?.email)}
</span>
<span className="min-w-0 flex-1">
<span className="block break-words text-[12.5px] leading-tight">
<span
className="block truncate text-[12.5px] leading-tight"
title={account?.email ?? undefined}
>
{account?.email ?? t("common.loading")}
</span>
{/* Reports what is actually configured. The old fixed "2FA
@@ -242,7 +247,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</div>
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
{SidebarContent}
</aside>
@@ -15,6 +15,7 @@ import {
Clock,
MailOpen,
Download,
FolderInput,
Pencil,
RefreshCw,
Trash2,
@@ -30,9 +31,11 @@ import {
listHeaderCount,
applySeen,
readerActions,
moveTargets,
type ListFilter,
} from "../../../../../lib/mailCounts";
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button";
@@ -113,6 +116,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const [messages, setMessages] = useState<MessageSummary[]>([]);
const [selected, setSelected] = useState<RenderedMessage | null>(null);
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
// Whether this sender has a standing decision, so the banner can offer to
// take it back rather than only to grant it.
const [rememberSender, setRememberSender] = useState(false);
// Whether the "move to" menu is open.
const [moveOpen, setMoveOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
const [loading, setLoading] = useState(true);
@@ -245,6 +253,30 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
[folder, t, loadMessages, searchQuery]
);
/** Files one message into another folder. */
const moveMessage = useCallback(
async (uid: number, target: string) => {
setMessages((current) => current.filter((m) => m.uid !== uid));
setSelected((current) => (current?.uid === uid ? null : current));
setMoveOpen(false);
try {
const res = await fetch("/api/v1/mail/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder, uid, target }),
});
if (!res.ok) throw new Error(t("errors.serverError"));
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.serverError"));
void loadMessages(searchQuery);
} finally {
// Both folders changed, so the badges have to re-read.
notifyMailStateChanged();
}
},
[folder, t, loadMessages, searchQuery]
);
const openMessage = async (uid: number) => {
// A draft is an unfinished message: opening it means carrying on writing,
// not reading it in a pane that offers to reply to yourself.
@@ -253,13 +285,19 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
return;
}
setLoadRemoteImages(false);
setRememberSender(false);
setReaderHeight(420);
try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(t("errors.serverError"));
setSelected(await res.json());
const message = await res.json();
setSelected(message);
// A sender the reader has already allowed does not get asked again.
const trusted = isSenderTrusted(message.from ?? "");
setLoadRemoteImages(trusted);
setRememberSender(trusted);
// Fetching the message is what marks it read on the server, so this only
// brings the list and the badges into line with what already happened.
void setSeen(uid, true, { persist: false });
@@ -329,7 +367,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<div className="flex flex-1 overflow-hidden">
{/* Messages List Column */}
<div
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[404px] md:flex-none`}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[380px] md:flex-none lg:w-[420px] xl:w-[460px] 2xl:w-[500px]`}
>
{/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */}
@@ -444,15 +482,20 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</span>
</span>
{/* The subject wraps instead of being clipped - a truncated
subject is the one thing a mail list must not do. */}
subject is the one thing a mail list must not do.
overflow-wrap:anywhere as well as wrapping, because a
report's Message-ID is a single unbroken "word" longer
than the column: with nowhere to break it, it ran
straight out of the row. Clamped so one such subject
cannot push the rest of the list off the screen. */}
<span
className={`text-[13.5px] leading-snug ${
className={`line-clamp-3 text-[13.5px] leading-snug [overflow-wrap:anywhere] ${
msg.seen ? "text-slate-300" : "text-slate-100"
}`}
>
{msg.subject || t("mail.noSubject")}
</span>
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400">
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
{msg.snippet}
</span>
{msg.has_attachments && (
@@ -503,7 +546,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7"
className="mx-auto flex w-full max-w-[860px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7 xl:max-w-[1000px] 2xl:max-w-[1140px]"
>
{/* Toolbar: closing, replying and read state all in one row, so
the reader is not the only pane with no way out of itself. */}
@@ -555,6 +598,33 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
)}
{/* Filing a message elsewhere: the one ordinary action the
reader still had no button for. */}
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setMoveOpen((open) => !open)}
title={t("mail.moveTo")}
>
<FolderInput className="h-3.5 w-3.5" />
<span>{t("mail.moveTo")}</span>
</Button>
{moveOpen && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge">
{moveTargets(folders, folder).map((target) => (
<button
key={target.name}
type="button"
onClick={() => void moveMessage(selected.uid, target.name)}
className="block w-full px-3.5 py-2 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]"
>
{FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name}
</button>
))}
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
@@ -623,18 +693,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{/* Remote content banner, shown only when something was actually
held back rather than over every message. The wording is the
reassurance, not the colour: this is not an error. */}
{hasBlockedContent && (
{(hasBlockedContent || rememberSender) && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{t("mail.remoteContentBlocked")}
</span>
{/* Two separate decisions: show them now, and stop asking
about this sender. Keeping them apart lets a reader look
at one message without granting anything permanently. */}
<Button
variant="ghost"
size="sm"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
>
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
{loadRemoteImages ? t("mail.hideImages") : t("mail.loadImages")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (rememberSender) {
revokeSender(selected.from);
setRememberSender(false);
setLoadRemoteImages(false);
} else {
trustSender(selected.from);
setRememberSender(true);
setLoadRemoteImages(true);
}
}}
>
{rememberSender ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import {
Shield,
Filter,
@@ -19,6 +19,7 @@ import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
interface SieveRule {
id: string;
@@ -71,6 +72,15 @@ export default function UserSettingsPage() {
const [vacationSaved, setVacationSaved] = useState(false);
const [signature, setSignature] = useState("");
const signatureRef = useRef<HTMLDivElement>(null);
// execCommand edits the DOM directly, so the mirrored value is re-read here
// rather than relying on an input event that may not be dispatched.
const applySignatureCommand = (command: string, value?: string) => {
signatureRef.current?.focus();
document.execCommand(command, false, value);
setSignature(signatureRef.current?.innerHTML ?? "");
};
const [sigSaved, setSigSaved] = useState(false);
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
@@ -88,7 +98,11 @@ export default function UserSettingsPage() {
fetch("/api/v1/user/preferences")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.signature) setSignature(data.signature);
if (data?.signature) {
const html = signatureToHtml(data.signature);
setSignature(html);
if (signatureRef.current) signatureRef.current.innerHTML = html;
}
})
.catch(() => undefined);
@@ -107,9 +121,9 @@ export default function UserSettingsPage() {
await fetch("/api/v1/user/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signature, auto_save_drafts: true }),
body: JSON.stringify({ signature: sanitizeSignature(signature), auto_save_drafts: true }),
});
localStorage.setItem("lm_user_signature", signature);
localStorage.setItem("lm_user_signature", sanitizeSignature(signature));
setSigSaved(true);
setTimeout(() => setSigSaved(false), 2000);
} catch {
@@ -748,13 +762,69 @@ export default function UserSettingsPage() {
<label htmlFor="signature-text" className={fieldLabel}>
{t("ui.messageBody")}
</label>
<textarea
{/* A rich field rather than a textarea. A signature is a piece
of a message - people put a link, a logo and bold type in
it - and the plain textarea could hold none of that. It
also stored real newlines, which are whitespace once the
value is injected into the composer as HTML, so every line
break was silently dropped there. */}
<div className="flex flex-wrap items-center gap-1 rounded-t-xl bg-dark-card px-2 py-1.5 shadow-edge">
{[
{ cmd: "bold", label: "B", title: t("ui.bold"), cls: "font-bold" },
{ cmd: "italic", label: "I", title: t("ui.italic"), cls: "italic" },
{ cmd: "underline", label: "U", title: t("ui.underline"), cls: "underline" },
].map((b) => (
<button
key={b.cmd}
type="button"
title={b.title}
aria-label={b.title}
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySignatureCommand(b.cmd)}
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
>
{b.label}
</button>
))}
<span className="mx-1 h-4 w-px bg-white/10" />
<button
type="button"
title={t("ui.insertLink")}
aria-label={t("ui.insertLink")}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const url = window.prompt(t("ui.insertLink"), "https://");
if (url) applySignatureCommand("createLink", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
>
{t("ui.insertLink")}
</button>
<button
type="button"
title={t("ui.insertImage")}
aria-label={t("ui.insertImage")}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const url = window.prompt(t("ui.insertImage"), "https://");
if (url) applySignatureCommand("insertImage", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
>
{t("ui.insertImage")}
</button>
</div>
<div
id="signature-text"
rows={7}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder={t("settings.signaturePlaceholder")}
className={`${input} leading-relaxed`}
ref={signatureRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={t("settings.signatureTitle")}
onInput={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
onBlur={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
className="min-h-[170px] rounded-b-xl bg-dark-panel px-3.5 py-3 text-[13.5px] leading-relaxed text-slate-200 shadow-edge focus:outline-none"
/>
</div>