fix(webmail): corrige os contadores das pastas e as ações do leitor

Todos estes foram reproduzidos na interface real, com a conta em
mail.lucasrguerra.dev.br, e não em teste.

Contadores das pastas. O badge caía para o total sempre que não havia
nada por ler, no mesmo lugar e no mesmo estilo do contador de não
lidas: uma caixa de entrada com catorze mensagens lidas mostrava "14",
e ler mais uma nunca movia o número - ele nunca esteve contando o que
o leitor pensava. Os dois números agora são visualmente distintos e o
de não lidas só aparece quando existe mensagem por ler.

Em Rascunhos o badge e o total são o mesmo número por definição, então
o sidebar imprimia-o duas vezes: "Rascunhos 1 1". A regra passou para
folderBadge, onde é testada, e o tamanho da pasta é renderizado num
único sítio.

Ações do leitor. Todas as mensagens ofereciam os mesmos quatro botões,
onde a pasta devia decidir. Um e-mail que o próprio utilizador enviou
oferecia "marcar como não lida" - uma flag que nada lê numa pasta onde
nada é entregue - e um rascunho por acabar oferecia Responder e
Encaminhar, mas nenhuma forma de o terminar. Abrir um rascunho agora
abre o compositor com o que estava escrito e adota o seu UID, para que
continuar a escrever substitua esse rascunho em vez de deixar o antigo
para trás. Em Lixeira o botão de apagar diz que apaga em definitivo,
porque é o que faz.

Anexos. t("mail.attachments") era chamado sem o parâmetro que a string
declara, e a interface mostrava literalmente "Anexos ({count})" nos
três idiomas. Na lista, que sabe que há anexos mas não quantos, passa a
nomear o facto em vez de imprimir uma contagem que não tem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 10:18:45 -03:00
1 parent 1dc87a06c8
commit 203316f2c3
9 files changed
+320 -46

No files matched your search

@@ -38,7 +38,7 @@ interface AttachedFile {
export default function ComposePage({
searchParams,
}: {
searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string }>;
searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string; draft?: string }>;
}) {
const t = useTranslations();
const params = searchParams ? use(searchParams) : {};
@@ -76,6 +76,43 @@ export default function ComposePage({
}
}, []);
// Reopening a draft loads what was written into the composer, and adopts its
// UID so carrying on writing replaces that draft instead of leaving the old
// copy behind and starting a second one.
//
// Opening a draft used to land in the reader, which offered to reply to the
// half-written message and gave no way to finish it.
const draftParam = params?.draft;
useEffect(() => {
const uid = Number(draftParam);
if (!Number.isFinite(uid) || uid <= 0) return;
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" });
if (!res.ok || cancelled) return;
const message = await res.json();
if (cancelled) return;
draftUidRef.current = uid;
setTo((message.to ?? []).join(", "));
setCc((message.cc ?? []).join(", "));
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
setSubject(message.subject ?? "");
const html = message.html || message.text || "";
if (editorRef.current) editorRef.current.innerHTML = html;
setBody(html);
} catch {
// A draft that cannot be loaded leaves an empty composer rather than
// a broken screen; the draft itself is untouched on the server.
}
})();
return () => {
cancelled = true;
};
}, [draftParam]);
// Saves the draft for real. This used to be a setTimeout that set the words
// "draft saved automatically" and nothing else - no request, no storage - so
// closing the tab lost the message while the screen had just said otherwise.
+25 -16
View File
@@ -24,7 +24,7 @@ import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
import { folderBadge } from "../../../lib/mailCounts";
import { initialsFor } from "../../../lib/initials";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
@@ -88,10 +88,11 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
// Drafts counts what is waiting rather than what is unread; see
// badgeCount, which is where that rule is tested.
const badge = badgeCount(folders, item.role);
const { total } = folderMetrics(folders, item.role);
// Both numbers plus which one to emphasise; see folderBadge, where
// that rule is tested. It used to fall back to printing the total in
// the unread badge's place, so an inbox of read mail showed "14" and
// reading another message never moved it.
const badge = folderBadge(folders, item.role);
return (
<Link
@@ -99,7 +100,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
href={item.href}
onClick={() => setMobileOpen(false)}
data-active={isActive}
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
title={badge.total > 0 ? t("mail.messagesInFolder", { count: badge.total }) : item.label}
className="lm-nav"
>
<span className="lm-nav-mark" />
@@ -109,19 +110,27 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* The folder's size, shown quietly beside the badge: the
sidebar reported unread only, so there was nowhere in the
interface that said how much mail a folder holds. */}
{total > 0 && badge > 0 && (
<span className="text-[10.5px] tabular-nums text-slate-500">{total}</span>
)}
{badge > 0 ? (
{/* The folder size, always in the same quiet style, so it
can never be mistaken for an unread count. */}
{badge.total > 0 && (
<span
className={`text-[11.5px] tabular-nums ${isActive ? "text-indigo-300" : "text-slate-400"}`}
className="text-[11.5px] tabular-nums text-slate-500"
title={t("mail.messagesInFolder", { count: badge.total })}
>
{badge}
{badge.total}
</span>
)}
{/* The unread count, only when there is unread mail, and
visibly distinct from the size beside it. */}
{badge.showsUnread && (
<span
className={`rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums ${
isActive ? "bg-indigo-500/25 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
}`}
title={t("mail.unreadCount", { count: badge.unread })}
>
{badge.unread}
</span>
) : (
total > 0 && (
<span className="text-[11.5px] tabular-nums text-slate-500">{total}</span>
)
)}
</span>
</Link>
@@ -2,6 +2,7 @@
import React, { use, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
@@ -14,6 +15,7 @@ import {
Clock,
MailOpen,
Download,
Pencil,
RefreshCw,
Trash2,
X,
@@ -27,6 +29,7 @@ import {
messageCounts,
listHeaderCount,
applySeen,
readerActions,
type ListFilter,
} from "../../../../../lib/mailCounts";
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
@@ -120,6 +123,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const { folders } = useFolders();
const metrics = folderMetrics(folders, folder);
const router = useRouter();
// Which buttons this folder's messages deserve. The reader offered the same
// four everywhere, so Sent and Drafts got a mark-as-unread that means
// nothing there, drafts got Reply instead of a way to finish writing, and
// nothing anywhere got a delete.
const actions = readerActions(folder);
const loadMessages = useCallback(
async (search: string) => {
@@ -237,6 +246,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
);
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.
if (actions.canEdit) {
router.push(`/user/compose?draft=${uid}`);
return;
}
setLoadRemoteImages(false);
setReaderHeight(420);
try {
@@ -444,7 +459,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<span className="mt-px flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-md bg-slate-800 px-2 py-0.5 text-[10.5px] text-slate-300">
<Paperclip className="h-2.5 w-2.5" />
{t("mail.attachments")}
{/* The list knows only that a row has attachments,
not how many, so it names the fact rather than
printing a count it does not have. */}
{t("mail.attachmentsFilter")}
</span>
</span>
)}
@@ -490,42 +508,61 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{/* Toolbar: closing, replying and read state all in one row, so
the reader is not the only pane with no way out of itself. */}
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
>
<Button variant="primary" size="sm">
<Reply className="h-3.5 w-3.5" />
<span>{t("mail.reply")}</span>
</Button>
</Link>
{/* A draft is unfinished, so the thing to do with it is carry
on writing. It used to offer Reply and Forward on the
user's own half-written message and no way to edit it. */}
{actions.canEdit && (
<Link href={`/user/compose?draft=${selected.uid}`}>
<Button variant="primary" size="sm">
<Pencil className="h-3.5 w-3.5" />
<span>{t("mail.continueDraft")}</span>
</Button>
</Link>
)}
{actions.canReply && (
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
>
<Button variant="primary" size="sm">
<Reply className="h-3.5 w-3.5" />
<span>{t("mail.reply")}</span>
</Button>
</Link>
)}
{/* Forward carries the subject but no recipient: it used to
prefill the original sender, which sends the message
straight back to whoever wrote it. */}
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="h-3.5 w-3.5" />
<span>{t("mail.forward")}</span>
{actions.canForward && (
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="h-3.5 w-3.5" />
<span>{t("mail.forward")}</span>
</Button>
</Link>
)}
{/* Not offered in Sent or Drafts: nothing delivers to those
folders, so an unread flag there says nothing. */}
{actions.canMarkUnread && (
<Button
variant="secondary"
size="sm"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
>
<MailOpen className="h-3.5 w-3.5" />
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
</Link>
<Button
variant="secondary"
size="sm"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
>
<MailOpen className="h-3.5 w-3.5" />
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => void deleteMessage(selected.uid)}
title={t("common.delete")}
title={actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("common.delete")}</span>
<span>{actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
</Button>
<Button
variant="secondary"
@@ -606,7 +643,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{selected.attachments.length > 0 && (
<div className="flex flex-col gap-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
{t("mail.attachments")}
{t("mail.attachments", { count: selected?.attachments.length ?? 1 })}
</div>
<div className="flex flex-wrap gap-2">
{selected.attachments.map((filename, idx) => (