Files
LambdaMail/services/webmail/src/mailCounts.test.ts
T
Lucas RayanandClaude Sonnet 5.5 50a67531ba feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio
Webmail
- Leitor de mensagem em componente proprio, com barra de comandos fixa,
  cabecalho compacto, faixa de imagens bloqueadas e chips de anexo.
- Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique),
  largura persistida e limites que preservam os dois paineis.
- Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula,
  colagem de listas, validacao e bloqueio de envio com endereco invalido.
- Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To
  e References nas respostas; prefixo Re:/Fwd: sem repeticao.
- Marcar como importante (estrela, filtro, linha da lista) e enviar com
  alta importancia (Importance/X-Priority), com etiqueta no leitor.
- Sanitizador: a regexp de URI customizada descartava todo atributo que nao
  fosse link (width, colspan, bgcolor...), o que desmontava tabelas de
  e-mail; imagens de fundo agora sao bloqueadas de forma reversivel.

Protocols
- POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP.
- Renderizacao expoe message_id, in_reply_to e important.
- Cabecalhos de resposta validados contra injecao.

Auth
- As views do admin de dominio eram globais: dashboard, fila de saida,
  DMARC, preflight e diagnostico mostravam dados de todos os dominios.
  Agora todas respeitam o escopo da sessao; super admin segue vendo tudo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:09:55 -03:00

381 lines
16 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
folderMetrics,
badgeCount,
messageCounts,
filterMessages,
applyFlagged,
listHeaderCount,
applySeen,
type FolderSummary,
type MessageSummary,
} from "./lib/mailCounts.js";
/**
* The counters the interface shows.
*
* Every case here is something the screens got wrong: the sidebar matched a
* folder by a role it does not always carry, the message list printed the
* length of the page it had loaded as if it were the size of the folder, that
* number then changed while typing in the search box, and marking a message
* read moved no counter at all.
*/
const folders: FolderSummary[] = [
{ special_use: "inbox", name: "INBOX", unread_count: 3, total_count: 42 },
{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 2 },
{ special_use: "", name: "Projects", unread_count: 5, total_count: 9 },
];
describe("resolving a folder", () => {
it("finds it by its special-use role", () => {
expect(folderMetrics(folders, "inbox")).toEqual({ unread: 3, total: 42 });
});
// A folder created by an IMAP client has no special-use role at all, so the
// route segment is all there is to match on.
it("falls back to the folder name when there is no role", () => {
expect(folderMetrics(folders, "projects")).toEqual({ unread: 5, total: 9 });
});
it("matches the name regardless of case, as IMAP INBOX requires", () => {
expect(folderMetrics(folders, "Inbox").total).toBe(42);
expect(folderMetrics([{ special_use: "", name: "INBOX", unread_count: 1, total_count: 7 }], "inbox").total)
.toBe(7);
});
// Before the folder list has loaded there is nothing to read counts from.
// Zero is the honest answer; NaN rendered as "NaN" in the badge.
it("reports zeros rather than NaN for a folder it does not have yet", () => {
expect(folderMetrics([], "inbox")).toEqual({ unread: 0, total: 0 });
expect(folderMetrics(undefined, "inbox")).toEqual({ unread: 0, total: 0 });
});
// A counter column can drift below zero on a bad migration; the interface
// must not be the place that publishes it.
it("never reports a negative count", () => {
const broken: FolderSummary[] = [{ special_use: "inbox", name: "INBOX", unread_count: -4, total_count: -1 }];
expect(folderMetrics(broken, "inbox")).toEqual({ unread: 0, total: 0 });
});
});
describe("the number on a sidebar folder", () => {
it("is the unread count for a folder that receives mail", () => {
expect(badgeCount(folders, "inbox")).toBe(3);
});
// Nothing marks a draft read, so its unread count is always zero and the
// badge was always absent - while the useful number, how many unfinished
// messages are waiting, was on hand the whole time.
it("is the total for Drafts, which has no notion of unread", () => {
expect(badgeCount(folders, "drafts")).toBe(2);
});
it("is absent, not a zero, when there is nothing to report", () => {
expect(badgeCount(folders, "sent")).toBe(0);
expect(badgeCount([{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 0 }], "drafts"))
.toBe(0);
});
});
const messages: MessageSummary[] = [
{ uid: 1, seen: false, has_attachments: true },
{ uid: 2, seen: true, has_attachments: false },
{ uid: 3, seen: false, has_attachments: false, flagged: true },
];
describe("important messages", () => {
it("filters to the flagged ones", () => {
expect(filterMessages(messages, "important").map((m) => m.uid)).toEqual([3]);
});
it("sets and clears the flag on one row only", () => {
const on = applyFlagged(messages, 1, true);
expect(on.map((m) => !!m.flagged)).toEqual([true, false, true]);
expect(applyFlagged(on, 1, false).map((m) => !!m.flagged)).toEqual([false, false, true]);
});
});
describe("counting the loaded page", () => {
it("counts what is on it by unread and by attachment", () => {
expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1, important: 1 });
});
it("counts an empty page as zeros", () => {
expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0, important: 0 });
});
});
describe("filtering the list", () => {
it("keeps everything when no filter is set", () => {
expect(filterMessages(messages, null)).toHaveLength(3);
});
it("keeps only unread, and only messages with attachments", () => {
expect(filterMessages(messages, "unread").map((m) => m.uid)).toEqual([1, 3]);
expect(filterMessages(messages, "attachment").map((m) => m.uid)).toEqual([1]);
});
});
describe("the count in the list header", () => {
// The header printed messages.length, which is the size of the page the API
// returned - capped at 50 - so a folder holding 4000 messages advertised 50.
it("is the folder's size, not the size of the page that was loaded", () => {
expect(listHeaderCount({ folder: { unread: 12, total: 4000 }, loaded: 50, searching: false })).toEqual({
count: 4000,
isFolderTotal: true,
});
});
// While searching, the folder total is not what the reader is looking at, and
// showing it made the number jump around as they typed.
it("is the number of matches while a search is active", () => {
expect(listHeaderCount({ folder: { unread: 3, total: 42 }, loaded: 2, searching: true })).toEqual({
count: 2,
isFolderTotal: false,
});
});
// A folder whose counter column is behind - it is denormalised - must not
// claim fewer messages than the page already on screen.
it("never reports fewer messages than are visible", () => {
expect(listHeaderCount({ folder: { unread: 0, total: 1 }, loaded: 12, searching: false })).toEqual({
count: 12,
isFolderTotal: true,
});
});
});
describe("marking a message read in the loaded list", () => {
it("flips the flag on that message only", () => {
const next = applySeen(messages, 1, true);
expect(next.find((m) => m.uid === 1)?.seen).toBe(true);
expect(next.find((m) => m.uid === 3)?.seen).toBe(false);
});
it("reduces the unread count of the page it returns", () => {
expect(messageCounts(applySeen(messages, 1, true)).unread).toBe(1);
});
it("can put a message back to unread", () => {
expect(messageCounts(applySeen(messages, 2, false)).unread).toBe(3);
});
// The list state is read by React, so a mutation in place would not re-render
// and the row would keep its bold styling until something else moved.
it("returns a new array rather than mutating the one it was given", () => {
const next = applySeen(messages, 1, true);
expect(next).not.toBe(messages);
expect(messages.find((m) => m.uid === 1)?.seen).toBe(false);
});
});
// --- what the sidebar actually renders -----------------------------------
import { folderBadge, readerActions, moveTargets, customFolders } from "./lib/mailCounts";
/**
* These reproduce what the running webmail shows, which the badgeCount tests
* above could not: they check the number, and the defect was in how the number
* is displayed.
*/
describe("the badge a sidebar folder renders", () => {
const folders = [
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 14 },
{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 1 },
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 1 },
{ special_use: "junk", name: "Junk", unread_count: 3, total_count: 9 },
{ special_use: "archive", name: "Archive", unread_count: 0, total_count: 0 },
];
// The sidebar shows what needs attention and nothing else. Printing the size
// of every folder beside its name filled the rail with numbers that never
// change and that nobody is waiting on.
it("shows the unread count when there is unread mail", () => {
const badge = folderBadge(folders, "junk");
expect(badge.showsUnread).toBe(true);
expect(badge.unread).toBe(3);
});
// An inbox whose mail has all been read shows nothing. This is also what the
// original defect looked like from the other side: it showed "14" - the
// total - and reading a message never moved it.
it("shows nothing when everything has been read", () => {
expect(folderBadge(folders, "inbox").showsUnread).toBe(false);
});
it("shows nothing for a folder nothing is delivered to", () => {
expect(folderBadge(folders, "sent").showsUnread).toBe(false);
});
it("shows nothing for an empty folder", () => {
expect(folderBadge(folders, "archive").showsUnread).toBe(false);
});
// Nothing marks a draft read, so its unread count is permanently zero. With
// only unread counts on show, Drafts carries no number at all.
it("shows nothing for drafts, which are never unread", () => {
expect(folderBadge(folders, "drafts").showsUnread).toBe(false);
});
// The total is still available to anything that wants it - the folder
// heading uses it - it is simply not what the rail displays.
it("still reports the folder size for callers that need it", () => {
expect(folderBadge(folders, "inbox").total).toBe(14);
});
it("copes with a folder that is not there", () => {
const badge = folderBadge(folders, "nao-existe");
expect(badge.showsUnread).toBe(false);
expect(badge.unread).toBe(0);
});
});
describe("the actions a message offers", () => {
// "Mark as unread" was offered on a message the user sent themselves and on
// their own half-written draft. Nothing reads those, so the flag means
// nothing there and the button only invites a pointless round trip.
it("does not offer mark-as-unread in Sent or Drafts", () => {
expect(readerActions("sent").canMarkUnread).toBe(false);
expect(readerActions("drafts").canMarkUnread).toBe(false);
});
it("offers mark-as-unread in the folders that receive mail", () => {
expect(readerActions("inbox").canMarkUnread).toBe(true);
expect(readerActions("archive").canMarkUnread).toBe(true);
});
// Reply and forward on your own unfinished draft make no sense either: the
// thing to do with a draft is finish it.
it("offers editing rather than replying on a draft", () => {
const draft = readerActions("drafts");
expect(draft.canEdit).toBe(true);
expect(draft.canReply).toBe(false);
expect(draft.canForward).toBe(false);
});
it("offers reply and forward on received mail", () => {
const inbox = readerActions("inbox");
expect(inbox.canReply).toBe(true);
expect(inbox.canForward).toBe(true);
expect(inbox.canEdit).toBe(false);
});
// Delete has to be offered everywhere: it was offered nowhere, which is why
// a draft left behind by a sent message could not be removed at all.
it("offers delete in every folder", () => {
for (const folder of ["inbox", "sent", "drafts", "archive", "junk", "trash"]) {
expect(readerActions(folder).canDelete).toBe(true);
}
});
// In Trash the same button has to say it destroys the message, because there
// it does - there is nowhere further to move it.
it("says delete is permanent once in Trash", () => {
expect(readerActions("trash").deleteIsPermanent).toBe(true);
expect(readerActions("inbox").deleteIsPermanent).toBe(false);
});
});
describe("where a message may be moved", () => {
const folders = [
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 3 },
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 1 },
{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 1 },
{ special_use: "archive", name: "Archive", unread_count: 0, total_count: 0 },
{ special_use: "junk", name: "Junk", unread_count: 0, total_count: 0 },
{ special_use: "trash", name: "Trash", unread_count: 0, total_count: 0 },
{ special_use: "", name: "Reports", unread_count: 0, total_count: 2 },
{ special_use: "", name: "Faturas", unread_count: 0, total_count: 0 },
];
// Sent and Drafts record how a message came to exist, not where it was
// filed. Offering them would let Sent claim a message it never sent.
it("never offers Sent or Drafts", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).not.toContain("Sent");
expect(names).not.toContain("Drafts");
});
it("does not offer the folder the message is already in", () => {
expect(moveTargets(folders, "inbox").map((f) => f.name)).not.toContain("INBOX");
expect(moveTargets(folders, "archive").map((f) => f.name)).not.toContain("Archive");
});
it("offers the ordinary destinations", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).toContain("Archive");
expect(names).toContain("Junk");
expect(names).toContain("Trash");
});
// A folder the user made themselves has no special-use role, and matching
// only on roles would leave it unreachable.
it("offers folders the user created", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).toContain("Reports");
expect(names).toContain("Faturas");
});
it("can move out of Drafts and Sent, even though nothing moves in", () => {
expect(moveTargets(folders, "drafts").map((f) => f.name)).toContain("Archive");
expect(moveTargets(folders, "sent").map((f) => f.name)).toContain("Archive");
});
});
describe("the folders a user made themselves", () => {
const folders = [
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 3 },
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 1 },
{ special_use: "trash", name: "Trash", unread_count: 0, total_count: 0 },
{ special_use: "", name: "Reports", unread_count: 0, total_count: 2 },
{ special_use: "", name: "Faturas", unread_count: 1, total_count: 4 },
{ special_use: "", name: "Clientes", unread_count: 0, total_count: 0 },
];
// The sidebar lists the standard folders from a fixed array, so a folder the
// user created appears nowhere unless it is picked out separately.
it("are the ones with no special-use role, minus the system's own", () => {
const names = customFolders(folders).map((f) => f.name);
expect(names).toEqual(["Clientes", "Faturas"]);
});
it("leaves out Reports, which this server fills itself", () => {
expect(customFolders(folders).map((f) => f.name)).not.toContain("Reports");
});
it("lists them in alphabetical order, since nothing else orders them", () => {
expect(customFolders(folders).map((f) => f.name)).toEqual(["Clientes", "Faturas"]);
});
it("copes with no folders at all", () => {
expect(customFolders([])).toEqual([]);
expect(customFolders(undefined)).toEqual([]);
});
});
describe("move targets when there is no current folder", () => {
const folders = [
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 1 },
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 0 },
{ special_use: "", name: "Faturas", unread_count: 0, total_count: 0 },
{ special_use: "", name: "Reports", unread_count: 0, total_count: 0 },
];
// The rules screen picks a destination without any message being open, so it
// has no "current folder" to exclude. Passing an empty string made
// `role === current` true for every folder with no special-use role - which
// is exactly the folders the user created - so the destination list silently
// left out the only folders a filing rule is usually written for.
it("offers the custom folders when no folder is excluded", () => {
const names = moveTargets(folders, "").map((f) => f.name);
expect(names).toContain("Faturas");
expect(names).toContain("Reports");
expect(names).toContain("INBOX");
});
it("still leaves Sent out", () => {
expect(moveTargets(folders, "").map((f) => f.name)).not.toContain("Sent");
});
});