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>
92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { signatureToHtml, sanitizeSignature } from "./lib/signature";
|
|
|
|
/**
|
|
* The signature is edited in one place and injected into the composer in
|
|
* another, and the two disagreed about what it was.
|
|
*
|
|
* It was typed into a plain <textarea>, so it held newlines - and then it was
|
|
* assigned with innerHTML, where a newline is whitespace and renders as
|
|
* nothing. Every line break a reader put in their signature was silently
|
|
* dropped the moment they composed a message.
|
|
*/
|
|
|
|
describe("a signature written as plain text", () => {
|
|
it("keeps its line breaks when it reaches the composer", () => {
|
|
const html = signatureToHtml("Lucas Rayan\nDesenvolvedor\nlucasrguerra.dev.br");
|
|
expect(html).toContain("Lucas Rayan<br>");
|
|
expect(html).toContain("Desenvolvedor<br>");
|
|
// Rendered, the three lines must still be three lines.
|
|
expect(html.match(/<br\s*\/?>/g) ?? []).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps blank lines, which are deliberate spacing", () => {
|
|
const html = signatureToHtml("Lucas\n\nDesenvolvedor");
|
|
expect(html.match(/<br\s*\/?>/g) ?? []).toHaveLength(2);
|
|
});
|
|
|
|
it("escapes characters that would otherwise become markup", () => {
|
|
const html = signatureToHtml("Lucas <lucas@example.test> & cia");
|
|
expect(html).toContain("<lucas@example.test>");
|
|
expect(html).toContain("&");
|
|
});
|
|
|
|
it("leaves a signature that is already HTML alone", () => {
|
|
const html = signatureToHtml("<p>Lucas <b>Rayan</b></p>");
|
|
expect(html).toContain("<b>Rayan</b>");
|
|
expect(html).not.toContain("<p>");
|
|
});
|
|
});
|
|
|
|
describe("what a signature may contain", () => {
|
|
it("allows the formatting a signature is actually made of", () => {
|
|
const html = sanitizeSignature(
|
|
'<p><b>Lucas</b> <i>Rayan</i><br><a href="https://lucasrguerra.dev.br">site</a></p>',
|
|
);
|
|
expect(html).toContain("<b>");
|
|
expect(html).toContain("<i>");
|
|
expect(html).toContain("<a");
|
|
expect(html).toContain("https://lucasrguerra.dev.br");
|
|
});
|
|
|
|
it("allows images, which is half of why people want HTML here", () => {
|
|
const html = sanitizeSignature('<img src="https://example.test/logo.png" alt="logo">');
|
|
expect(html).toContain("<img");
|
|
expect(html).toContain("logo.png");
|
|
});
|
|
|
|
it("allows an inline image pasted as a data URI", () => {
|
|
const html = sanitizeSignature(
|
|
'<img src="data:image/png;base64,iVBORw0KGgo=" alt="logo">',
|
|
);
|
|
expect(html).toContain("data:image/png;base64");
|
|
});
|
|
|
|
// The signature is injected into the composer as markup, so anything that
|
|
// survives here executes on this origin the next time a message is written.
|
|
it("strips script", () => {
|
|
const html = sanitizeSignature('<p>Lucas</p><script>alert(1)</script>');
|
|
expect(html).not.toContain("<script");
|
|
expect(html).not.toContain("alert(1)");
|
|
});
|
|
|
|
it("strips event handlers", () => {
|
|
const html = sanitizeSignature('<img src="x" onerror="alert(1)">');
|
|
expect(html.toLowerCase()).not.toContain("onerror");
|
|
});
|
|
|
|
it("strips a javascript: link", () => {
|
|
const html = sanitizeSignature('<a href="javascript:alert(1)">click</a>');
|
|
expect(html.toLowerCase()).not.toContain("javascript:");
|
|
});
|
|
|
|
it("refuses a non-image data URI", () => {
|
|
const html = sanitizeSignature('<a href="data:text/html,<script>alert(1)</script>">x</a>');
|
|
expect(html.toLowerCase()).not.toContain("data:text/html");
|
|
});
|
|
|
|
it("returns empty for empty input rather than throwing", () => {
|
|
expect(sanitizeSignature("")).toBe("");
|
|
});
|
|
});
|