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>
381 lines
16 KiB
TypeScript
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");
|
|
});
|
|
});
|