Files
LambdaMail/services/webmail/src/lib/recipients.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

161 lines
5.3 KiB
TypeScript

/**
* 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, "")}`;
}