fix(webmail): acertos de interface e mover mensagens entre pastas
Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês que restava, e "Bcc" tem nome próprio em português - quem não conhece a abreviatura inglesa não adivinha o que o campo faz. Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console de administração", que transbordava no telemóvel e no computador: o botão passa a mostrar um rótulo curto e guarda o nome completo no tooltip. O endereço da conta usava break-words e partia-se a meio do domínio, deixando um "br" sozinho na linha de baixo; passa a truncar com o valor inteiro no tooltip. A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã largo os assuntos eram espremidos numa coluna estreita enquanto o leitor ficava entre duas margens enormes. Ambos crescem agora com a janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do Google são uma única "palavra" sem espaços, mais longa que a coluna, e sem sítio onde quebrar saíam da linha. Imagens remotas. A escolha era reposta sempre que uma mensagem era aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada leitura. A decisão passa a ser lembrada por remetente, com um segundo botão para a retirar. Bloqueado continua a ser o que acontece por omissão: uma imagem remota é um aviso de leitura que o remetente não pediu licença para receber. Assinatura. Era escrita num textarea - texto simples com quebras de linha reais - e injetada no compositor com innerHTML, onde uma quebra de linha é espaço em branco: todas desapareciam. Passa a ser um campo rico, com negrito, itálico, links e imagens, guardado como HTML e sanitizado nos dois sentidos, já que este valor é injetado como marcação na origem do webmail. Assinaturas guardadas pelo campo antigo continuam a funcionar: as suas quebras de linha são convertidas. Mover entre pastas. Faltava a única ação corrente que o leitor não tinha: arquivar uma mensagem para fora da caixa de entrada era impossível. Enviados e Rascunhos nunca são destino - descrevem como a mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é permitido. Pastas criadas pelo utilizador, que não têm papel special-use, são destino como qualquer outra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac0521cb9b
commit
f179cb1f27
20 files changed
+911
-37
No files matched your search
@@ -194,3 +194,31 @@ export function readerActions(folder: string): ReaderActions {
|
||||
deleteIsPermanent: role === "trash",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders a message may be filed into, from where it is now.
|
||||
*
|
||||
* Sent and Drafts are never offered. They record how a message came to exist
|
||||
* rather than where its reader put it: moving arbitrary mail into Sent would
|
||||
* have the folder claim it was sent from here, and a message in Drafts that
|
||||
* cannot be edited would sit among the unfinished ones forever. Moving *out*
|
||||
* of them is fine, which is why the exclusion is on the destination only.
|
||||
*
|
||||
* Matching is by name as well as role, so a folder the user created - which
|
||||
* has no special-use role at all - is a destination like any other.
|
||||
*/
|
||||
export function moveTargets(
|
||||
folders: FolderSummary[] | undefined | null,
|
||||
currentRoute: string,
|
||||
): FolderSummary[] {
|
||||
const current = currentRoute.trim().toLowerCase();
|
||||
return (folders ?? []).filter((folder) => {
|
||||
const role = (folder.special_use ?? "").toLowerCase();
|
||||
const name = (folder.name ?? "").toLowerCase();
|
||||
if (role === "sent" || role === "drafts") return false;
|
||||
if (name === "sent" || name === "drafts") return false;
|
||||
// Already here: nothing to do.
|
||||
if (role === current || name === current) return false;
|
||||
return Boolean(folder.name);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Which senders may load their remote images.
|
||||
*
|
||||
* Remote images are a read receipt the sender did not ask permission for: a
|
||||
* unique URL per recipient tells them the message was opened, when, and from
|
||||
* roughly where. So the default is blocked and stays blocked - only an
|
||||
* explicit decision by the reader changes it.
|
||||
*
|
||||
* What was missing was memory. The choice was reset every time a message was
|
||||
* opened, so the same newsletter had to be unblocked again on every read, and
|
||||
* there was no way to say "this sender is fine, stop asking".
|
||||
*
|
||||
* The decision is per sender rather than per message, which is how mail
|
||||
* clients have always framed it: the question a reader is really answering is
|
||||
* whether they trust who sent it.
|
||||
*/
|
||||
|
||||
export const TRUSTED_SENDERS_KEY = "lm_remote_images_trusted";
|
||||
|
||||
/** How many senders are remembered before the oldest decisions are dropped. */
|
||||
const MAX_REMEMBERED = 200;
|
||||
|
||||
/**
|
||||
* The address inside a From header, lowercased.
|
||||
*
|
||||
* Headers arrive as `Jenny <jenny@example.test>` or as a bare address, and the
|
||||
* same sender must resolve to one key either way or a decision made on one
|
||||
* message would not apply to the next.
|
||||
*/
|
||||
export function senderKey(from: string): string {
|
||||
const raw = (from ?? "").trim();
|
||||
if (!raw) return "";
|
||||
const angled = raw.match(/<([^>]+)>/);
|
||||
const address = (angled ? angled[1] : raw).trim().toLowerCase();
|
||||
return address.includes("@") ? address : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the stored list.
|
||||
*
|
||||
* Every access is guarded: a private window, cleared site data, or a browser
|
||||
* set to refuse storage all throw here, and none of them is a reason for the
|
||||
* reader to fail to render. Failing closed means falling back to blocked,
|
||||
* which is the safe direction.
|
||||
*/
|
||||
function readTrusted(): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeTrusted(list: string[]): void {
|
||||
try {
|
||||
// Newest first, capped: a mailbox read for years would otherwise grow this
|
||||
// list without limit, and the oldest decisions are the least likely to
|
||||
// still reflect what the reader wants.
|
||||
window.localStorage.setItem(
|
||||
TRUSTED_SENDERS_KEY,
|
||||
JSON.stringify(list.slice(0, MAX_REMEMBERED)),
|
||||
);
|
||||
} catch {
|
||||
// Nothing is remembered this session; the reader still works.
|
||||
}
|
||||
}
|
||||
|
||||
export function isSenderTrusted(from: string): boolean {
|
||||
const key = senderKey(from);
|
||||
if (!key) return false;
|
||||
return readTrusted().includes(key);
|
||||
}
|
||||
|
||||
export function trustSender(from: string): void {
|
||||
const key = senderKey(from);
|
||||
if (!key) return;
|
||||
const current = readTrusted().filter((entry) => entry !== key);
|
||||
writeTrusted([key, ...current]);
|
||||
}
|
||||
|
||||
export function revokeSender(from: string): void {
|
||||
const key = senderKey(from);
|
||||
if (!key) return;
|
||||
writeTrusted(readTrusted().filter((entry) => entry !== key));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
/**
|
||||
* The signature, on its way from the settings screen into a message.
|
||||
*
|
||||
* It used to be a plain <textarea> whose contents were assigned straight into
|
||||
* the composer with innerHTML. A newline is whitespace in HTML, so every line
|
||||
* break the reader typed was dropped the moment they wrote a message, and a
|
||||
* signature could not contain a link or an image at all.
|
||||
*
|
||||
* It is now HTML end to end, which means it has to be sanitised: whatever is
|
||||
* stored here is injected as markup into the composer on this origin every
|
||||
* time a message is written.
|
||||
*/
|
||||
|
||||
/** Tags a signature is legitimately made of. */
|
||||
const ALLOWED_TAGS = [
|
||||
"a", "b", "br", "div", "em", "i", "img", "li", "ol", "p", "span",
|
||||
"strong", "u", "small", "sub", "sup", "table", "tbody", "td", "tr", "ul", "font",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
"alt", "align", "border", "color", "face", "height", "href", "rel", "size",
|
||||
"src", "style", "target", "title", "width",
|
||||
];
|
||||
|
||||
/** Only images may be data URIs, and only real image types. */
|
||||
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
|
||||
|
||||
let hookInstalled = false;
|
||||
function installHook(): void {
|
||||
if (hookInstalled) return;
|
||||
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
if (/^\s*data:/i.test(data.attrValue) && !ALLOWED_DATA_IMAGE.test(data.attrValue.trim())) {
|
||||
// A data: URI that is not an image is a document, and a document here
|
||||
// is script on this origin.
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
hookInstalled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a signature for storage and for injection.
|
||||
*
|
||||
* Fails closed without a DOM: returning the input unsanitised would put raw
|
||||
* markup into the composer, which is the one outcome worth avoiding.
|
||||
*/
|
||||
export function sanitizeSignature(html: string): string {
|
||||
if (!html) return "";
|
||||
if (typeof window === "undefined" || !DOMPurify.isSupported) return "";
|
||||
installHook();
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
// Keeps the sanitiser from being talked into treating markup as SVG/MathML,
|
||||
// where the tag allowlist above does not apply the same way.
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored signature is markup or the plain text of the old field.
|
||||
*
|
||||
* Matched against real tag names rather than "a < followed by a letter": a
|
||||
* signature that names an address the ordinary way - Lucas <lucas@example.test>
|
||||
* - satisfies the loose test, and treating it as markup made the sanitiser
|
||||
* read <lucas@example.test> as an unknown tag and delete the address.
|
||||
*/
|
||||
const HTML_TAG = new RegExp(
|
||||
`</?(?:${ALLOWED_TAGS.join("|")}|h[1-6]|hr|blockquote|pre|code)\\b[^>]*>`,
|
||||
"i",
|
||||
);
|
||||
|
||||
function looksLikeHtml(value: string): boolean {
|
||||
return HTML_TAG.test(value);
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature as markup, whichever form it was stored in.
|
||||
*
|
||||
* Signatures written before this was an HTML field are plain text with real
|
||||
* newlines, and they have to keep working: each newline becomes a <br>, and
|
||||
* the text itself is escaped so an address written as <me@example.test> does
|
||||
* not disappear into a tag that never closes.
|
||||
*/
|
||||
export function signatureToHtml(stored: string): string {
|
||||
const value = stored ?? "";
|
||||
if (!value.trim()) return "";
|
||||
if (looksLikeHtml(value)) return sanitizeSignature(value);
|
||||
// Escaped text is safe by construction, and it must not be sanitised again:
|
||||
// the sanitiser decodes the entities first, so an address written as
|
||||
// <me@example.test> would be read back as an unknown tag and dropped - which
|
||||
// is how a signature could lose the very line it was there to carry.
|
||||
return escapeText(value).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
Reference in new issue
Block a user