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>
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|