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>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-13 10:09:55 -03:00
1 parent b5baf33a63
commit 50a67531ba
30 files changed
+1810 -348

No files matched your search

+10 -1
View File
@@ -21,6 +21,8 @@ export interface MessageSummary {
uid: number;
seen: boolean;
has_attachments: boolean;
/** Marked important (\\Flagged). */
flagged?: boolean;
}
export interface FolderMetrics {
@@ -28,7 +30,7 @@ export interface FolderMetrics {
total: number;
}
export type ListFilter = "unread" | "attachment" | null;
export type ListFilter = "unread" | "attachment" | "important" | null;
/** Clamps a counter to something displayable: no negatives, no NaN. */
function counter(value: unknown): number {
@@ -73,17 +75,20 @@ export function messageCounts(messages: MessageSummary[]): {
loaded: number;
unread: number;
withAttachments: number;
important: number;
} {
return {
loaded: messages.length,
unread: messages.filter((m) => !m.seen).length,
withAttachments: messages.filter((m) => m.has_attachments).length,
important: messages.filter((m) => m.flagged).length,
};
}
export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] {
if (filter === "unread") return messages.filter((m) => !m.seen);
if (filter === "attachment") return messages.filter((m) => m.has_attachments);
if (filter === "important") return messages.filter((m) => m.flagged);
return messages;
}
@@ -121,6 +126,10 @@ export function applySeen<T extends MessageSummary>(messages: T[], uid: number,
return messages.map((m) => (m.uid === uid ? { ...m, seen } : m));
}
export function applyFlagged<T extends MessageSummary>(messages: T[], uid: number, flagged: boolean): T[] {
return messages.map((m) => (m.uid === uid ? { ...m, flagged } : m));
}
/** What the sidebar shows beside one folder. */
export interface FolderBadge {
/** How many unread messages the folder holds. */
+40
View File
@@ -0,0 +1,40 @@
/**
* The width of the message list, which the reader can drag to change.
*
* Both panes share one row, so a width has to leave the other pane enough room
* to be usable: a list dragged to the full window would leave no reader, and
* one dragged to nothing would leave subjects unreadable. The limits live here
* so they are tested rather than scattered through the drag handler.
*/
export const PANE_KEY = "lm_list_width";
export const DEFAULT_LIST_WIDTH = 420;
export const MIN_LIST_WIDTH = 300;
/** What the reader keeps whatever the list is dragged to. */
export const MIN_READER_WIDTH = 520;
export function clampListWidth(width: number, containerWidth: number): number {
if (Number.isNaN(width)) return DEFAULT_LIST_WIDTH;
const max = Math.max(MIN_LIST_WIDTH, containerWidth - MIN_READER_WIDTH);
return Math.round(Math.min(max, Math.max(MIN_LIST_WIDTH, width)));
}
/** The stored width, or null when there is none or it is not a number. */
export function readStoredWidth(): number | null {
try {
const raw = window.localStorage.getItem(PANE_KEY);
const value = raw === null ? NaN : Number(raw);
return Number.isFinite(value) ? value : null;
} catch {
// Storage can throw (private window, blocked site data); the default holds.
return null;
}
}
export function storeWidth(width: number): void {
try {
window.localStorage.setItem(PANE_KEY, String(Math.round(width)));
} catch {
// Not remembered this session; the divider still works.
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Reading and answering address lists.
*
* The compose field used to split on every comma, which cuts a display name
* like `"Silva, João" <joao@x.test>` in half and sends to two broken
* addresses. Reply-all had no home at all: the composer took one `replyTo`
* and nothing else, so answering a thread meant retyping everyone on it.
*/
export interface ParsedAddress {
/** The entry as it will be sent: `Name <addr>` or a bare address. */
entry: string;
/** The mailbox itself, lowercased for comparison. */
address: string;
/** What a chip shows: the name when there is one, else the address. */
label: string;
valid: boolean;
}
const MAILBOX = /^[^\s@<>(),;:"\\[\]]+@[^\s@<>(),;:"\\[\]]+\.[^\s@<>(),;:"\\[\]]+$/;
export function isValidAddress(address: string): boolean {
return address.length <= 254 && MAILBOX.test(address.trim());
}
/** The mailbox inside `Name <addr>`, or the text itself when it is bare. */
export function addressOf(entry: string): string {
const angled = entry.match(/<([^>]*)>\s*$/);
return (angled ? angled[1] : entry).trim().toLowerCase();
}
function nameOf(entry: string): string {
const angled = entry.match(/^\s*"?(.*?)"?\s*<[^>]*>\s*$/);
return angled ? angled[1].replace(/\\(.)/g, "$1").trim() : "";
}
/**
* Splits text on commas, semicolons and line breaks that are not inside a
* quoted name or an angle-bracketed address.
*/
function splitOutsideQuotes(text: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
let angled = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted && ch === "\\" && i + 1 < text.length) {
current += ch + text[++i];
continue;
}
if (ch === '"') quoted = !quoted;
else if (!quoted && ch === "<") angled = true;
else if (!quoted && ch === ">") angled = false;
if (!quoted && !angled && (ch === "," || ch === ";" || ch === "\n" || ch === "\r")) {
parts.push(current);
current = "";
} else {
current += ch;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter(Boolean);
}
export function parseAddress(raw: string): ParsedAddress {
const entry = raw.trim();
const address = addressOf(entry);
const name = nameOf(entry);
return { entry, address, label: name || address, valid: isValidAddress(address) };
}
/**
* Parses pasted or typed text into addresses.
*
* A run of bare addresses separated only by spaces - what copying from a mail
* header or a spreadsheet produces - is split as well, but only when every
* piece is itself an address, so a display name with spaces is never broken.
*/
export function parseAddressList(text: string): ParsedAddress[] {
const out: ParsedAddress[] = [];
for (const part of splitOutsideQuotes(text)) {
const pieces = part.split(/\s+/);
if (pieces.length > 1 && !/[<>"]/.test(part) && pieces.every((p) => isValidAddress(p))) {
for (const p of pieces) out.push(parseAddress(p));
} else {
out.push(parseAddress(part));
}
}
return out;
}
/** The list without repeats, comparing mailboxes and keeping the first entry. */
export function dedupe(entries: string[]): string[] {
const seen = new Set<string>();
return entries.filter((entry) => {
const key = addressOf(entry);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
interface Thread {
from: string;
to: string[];
cc: string[];
}
export interface Recipients {
to: string[];
cc: string[];
}
/**
* Who a plain reply goes to: the sender. Answering a message the user wrote
* themselves - from Sent - means following up with its recipients, not
* writing to oneself.
*/
export function replyRecipients(message: Thread, own: string): Recipients {
const me = own.trim().toLowerCase();
if (me && addressOf(message.from) === me) {
return { to: dedupe(message.to), cc: [] };
}
return { to: [message.from], cc: [] };
}
/**
* Who reply-all goes to: the sender and everyone else who was addressed.
*
* The user's own address is dropped everywhere - mailing oneself on every
* reply is the classic reply-all mistake - and nobody is listed twice, in
* To and again in Cc.
*/
export function replyAllRecipients(message: Thread, own: string): Recipients {
const me = own.trim().toLowerCase();
const notMe = (entry: string) => addressOf(entry) !== me;
const base = replyRecipients(message, own);
const to = dedupe([...base.to, ...message.to].filter(notMe));
const taken = new Set(to.map(addressOf));
const cc = dedupe(message.cc.filter(notMe)).filter((entry) => !taken.has(addressOf(entry)));
return { to, cc };
}
/** Whether reply-all would reach anyone a plain reply would not. */
export function offersReplyAll(message: Thread, own: string): boolean {
const single = replyRecipients(message, own);
const all = replyAllRecipients(message, own);
const reached = new Set(single.to.map(addressOf));
return [...all.to, ...all.cc].some((entry) => !reached.has(addressOf(entry)));
}
/** `Re:` or `Fwd:` once, however many times the subject has been through. */
export function prefixSubject(subject: string, mode: "reply" | "forward"): string {
if (!subject) return "";
if (mode === "forward") return `Fwd: ${subject.replace(/^((Fwd?|Enc|Rv):\s*)+/i, "")}`;
return `Re: ${subject.replace(/^((Re|Res):\s*)+/i, "")}`;
}
+27 -7
View File
@@ -53,7 +53,7 @@ export function sanitizeEmailHtml(rawHtml: string): string {
],
ALLOWED_ATTR: [
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
"class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
"src", "style", "title", "width", "target", "rel", "data-blocked-src",
],
// style, link, meta and base are gone: a stylesheet in a message can cover
@@ -67,24 +67,44 @@ export function sanitizeEmailHtml(rawHtml: string): string {
// message's own inline parts, and rejecting them stripped the src from
// every embedded picture. image/svg+xml is deliberately absent: an SVG is
// a document that carries script.
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);)/i,
//
// The last two alternatives are DOMPurify's own default for values that are
// not a URL at all ("600", "50%", "center", "#fff"). A custom pattern
// replaces that default, and without them every attribute whose value is
// not a link - width, height, colspan, align, bgcolor, cellpadding - was
// stripped, which is what flattened table layouts. A value with an
// unlisted scheme (javascript:, vbscript:, data:text/html) matches neither:
// the scheme name has to be followed by something other than a colon.
ALLOWED_URI_REGEXP:
/^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
}
/** Marks a CSS url() the browser cannot fetch, and that unblocking can undo. */
const BLOCKED_SCHEME = "lm-blocked-";
/**
* Replaces remote image sources with data-blocked-src so a tracking pixel
* cannot fire before the reader asks for images.
* Holds remote images back so a tracking pixel cannot fire before the reader
* asks for images.
*
* Every rewrite is reversible. Backgrounds used to be deleted outright, so
* choosing "show images" restored the <img> tags and never the banner behind
* them - the part of a marketing message that carries its look.
*/
export function blockRemoteImages(html: string): string {
if (!html) return "";
return html
.replace(/(<img\b[^>]*?\b)src\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-src=$2$3$2")
.replace(/(<[^>]+)background\s*=\s*("|')https?:[^"']*\2/gi, "$1")
.replace(/url\(\s*['"]?https?:[^)]*\)/gi, "none");
.replace(/(<[^>]+?\s)background\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-background=$2$3$2")
// An unknown scheme is never fetched, and is not a valid image either.
.replace(/url\(\s*(['"]?)(https?:)/gi, `url($1${BLOCKED_SCHEME}$2`);
}
/** Restores the blocked sources once the reader opts in. */
export function unblockRemoteImages(html: string): string {
if (!html) return "";
return html.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2");
return html
.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2")
.replace(/(<[^>]+?\s)data-blocked-background\s*=\s*("|')([^"']+)\2/gi, "$1background=$2$3$2")
.replace(new RegExp(`${BLOCKED_SCHEME}(https?:)`, "gi"), "$1");
}