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:
1 parent
b5baf33a63
commit
50a67531ba
30 files changed
+1810
-348
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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, "")}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user