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:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:18:08 -03:00
1 parent ac0521cb9b
commit f179cb1f27
20 files changed
+911 -37

No files matched your search

@@ -64,6 +64,10 @@ type WebmailRepository interface {
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
// Expunge soft-deletes one message, keeping the folder counters in step.
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
// MoveToFolder relocates one message into a named folder and returns its
// new UID there. A UID is meaningful only inside one folder, so the
// message keeps its identity and its blob but is renumbered.
MoveToFolder(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error)
// MoveToTrash relocates one message into Trash and returns its new UID
// there. This is what deleting a message means everywhere but in Trash
// itself, where Expunge is the operation instead.
@@ -2,6 +2,8 @@ package usecase
import (
"context"
"errors"
"strconv"
"strings"
"testing"
@@ -126,6 +128,7 @@ type stubWebmailRepo struct {
expungeErr error
movedToTrash []uint32
moveErr error
moved []string
}
func (s *stubWebmailRepo) FindMailboxIDByAddress(context.Context, string) (string, error) {
@@ -145,6 +148,16 @@ func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) er
s.expunged = append(s.expunged, uid)
return s.expungeErr
}
func (s *stubWebmailRepo) MoveToFolder(
_ context.Context, _, from string, uid uint32, to string,
) (uint32, error) {
if s.moveErr != nil {
return 0, s.moveErr
}
s.moved = append(s.moved, from+"->"+to+"/"+strconv.FormatUint(uint64(uid), 10))
return uid, nil
}
func (s *stubWebmailRepo) MoveToTrash(_ context.Context, _, _ string, uid uint32) (uint32, error) {
if s.moveErr != nil {
return 0, s.moveErr
@@ -372,3 +385,79 @@ func TestDeleteFromTrashExpunges(t *testing.T) {
t.Errorf("emptying from Trash did not remove the message: %v", repo.expunged)
}
}
// --- moving between folders ---------------------------------------------
// Filing a message somewhere else is the one ordinary mail action the webmail
// still had no way to perform.
func TestMoveFilesTheMessageInTheTargetFolder(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "Archive"); err != nil {
t.Fatalf("move: %v", err)
}
if len(repo.moved) != 1 || repo.moved[0] != "inbox->Archive/5" {
t.Errorf("moved %v", repo.moved)
}
}
// Sent and Drafts describe how a message came to exist, not where the reader
// filed it. Moving arbitrary mail into them would make the folder lie: Sent
// would hold messages that were never sent, and a draft that cannot be edited
// would sit among the unfinished ones.
func TestMoveRefusesSentAndDrafts(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
for _, target := range []string{"Sent", "sent", "Drafts", "DRAFTS"} {
err := uc.Move(context.Background(), "me@example.test", "inbox", 5, target)
if !errors.Is(err, ErrFolderNotATarget) {
t.Errorf("moving into %q returned %v, want ErrFolderNotATarget", target, err)
}
}
if len(repo.moved) != 0 {
t.Errorf("a refused move still touched the repository: %v", repo.moved)
}
}
// Moving a message into the folder it is already in is a no-op, not an error:
// it is what a double click on the same target produces.
func TestMoveIntoTheSameFolderDoesNothing(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "inbox"); err != nil {
t.Fatalf("move into the same folder: %v", err)
}
if len(repo.moved) != 0 {
t.Errorf("a no-op move still touched the repository: %v", repo.moved)
}
}
func TestMoveRejectsAnEmptyTarget(t *testing.T) {
mailboxID := uuid.New()
uc := &WebmailUseCase{
repo: &stubWebmailRepo{mailboxID: mailboxID.String()},
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, " "); err == nil {
t.Error("an empty target should be refused")
}
}
@@ -207,6 +207,47 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
return nil
}
// ErrFolderNotATarget refuses a move into a folder that describes how a
// message came to exist rather than where its reader filed it.
var ErrFolderNotATarget = errors.New("webmail: messages cannot be moved into this folder")
// ErrNoTargetFolder rejects a move with no destination named.
var ErrNoTargetFolder = errors.New("webmail: a destination folder is required")
// notAMoveTarget are the folders a message may never be moved into.
//
// Sent and Drafts are not places a reader files things; they record what a
// message is. Moving arbitrary mail into Sent would make it claim the message
// was sent from here, and a message in Drafts that cannot be edited would sit
// among the unfinished ones forever.
var notAMoveTarget = map[string]bool{"sent": true, "drafts": true}
// Move files one message into another folder.
//
// The webmail could delete a message and it could read one, but it had no way
// to put it anywhere: archiving something out of the inbox was impossible.
func (uc *WebmailUseCase) Move(ctx context.Context, address, folder string, uid uint32, target string) error {
target = strings.TrimSpace(target)
if target == "" {
return ErrNoTargetFolder
}
if notAMoveTarget[strings.ToLower(target)] {
return ErrFolderNotATarget
}
// Moving a message where it already is is what a second click on the same
// target produces; it is a no-op rather than an error.
if strings.EqualFold(target, folder) {
return nil
}
mailboxID, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
_, err = uc.repo.MoveToFolder(ctx, mailboxID, folder, uid, target)
return err
}
// Delete removes one message the way a mail client does: from any ordinary
// folder it goes to Trash, and from Trash it is really expunged.
//
@@ -299,6 +299,28 @@ func (r *WebmailRepository) Expunge(
// Trash itself is Expunge's job, not this one's.
func (r *WebmailRepository) MoveToTrash(
ctx context.Context, mailboxID, folderName string, uid uint32,
) (uint32, error) {
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
}
// MoveToFolder files one message into a named folder.
//
// The target is matched on its special-use role or its name, the same way the
// rest of this repository addresses folders, so "archive" and "Archive" both
// reach the same place and a folder the user created themselves - which has no
// role - is reachable by name.
func (r *WebmailRepository) MoveToFolder(
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
) (uint32, error) {
return r.moveInto(ctx, mailboxID, folderName, uid, target)
}
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
// callers cannot drift.
const trashTarget = "trash"
func (r *WebmailRepository) moveInto(
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
) (uint32, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
@@ -333,11 +355,11 @@ func (r *WebmailRepository) MoveToTrash(
var trashUID int64
err = tx.QueryRow(ctx, `
SELECT id::text, uid_next FROM folders
WHERE mailbox_id = $1 AND (special_use = 'trash' OR LOWER(name) = 'trash')
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
ORDER BY special_use IS NOT NULL DESC
LIMIT 1
FOR UPDATE
`, mailboxID).Scan(&trashFolderID, &trashUID)
`, mailboxID, target).Scan(&trashFolderID, &trashUID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, port.ErrNoTrashFolder
}
@@ -12,6 +12,7 @@ import (
"github.com/emersion/go-message"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/postgres"
)
@@ -290,6 +291,37 @@ func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleMove files one message into another folder.
func (m *mailAPI) handleMove(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
var body struct {
Folder string `json:"folder"`
UID uint32 `json:"uid"`
Target string `json:"target"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
if body.Folder == "" {
body.Folder = "inbox"
}
if body.UID == 0 {
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
return
}
if err := m.useCase.Move(r.Context(), session.Email, body.Folder, body.UID, body.Target); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"moved": true})
}
// handleSaveDraft stores the message being composed in the Drafts folder.
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
@@ -370,6 +402,13 @@ func (m *mailAPI) fail(w http.ResponseWriter, err error) {
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
case errors.Is(err, usecase.ErrFolderNotATarget):
writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET",
"Messages cannot be moved into Sent or Drafts")
case errors.Is(err, usecase.ErrNoTargetFolder):
writeError(w, http.StatusBadRequest, "NO_TARGET_FOLDER", "A destination folder is required")
case errors.Is(err, port.ErrNoTrashFolder):
writeError(w, http.StatusNotFound, "NOT_FOUND", "That folder does not exist")
case errors.Is(err, usecase.ErrDraftsUnavailable):
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
default:
@@ -89,6 +89,16 @@ func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid
return nil
}
func (f *fakeMailStore) MoveToFolder(
_ context.Context, mailboxID, folder string, uid uint32, target string,
) (uint32, error) {
if f.moveErr != nil {
return 0, f.moveErr
}
f.movedCalls = append(f.movedCalls, mailboxID+"/"+folder+"->"+target)
return uid, nil
}
func (f *fakeMailStore) MoveToTrash(
_ context.Context, mailboxID, folder string, uid uint32,
) (uint32, error) {
@@ -90,6 +90,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
r.mux.HandleFunc("/api/v1/mail/move", r.handleMailMove)
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
}
@@ -316,6 +317,17 @@ func (r *Router) handleMailDelete(w http.ResponseWriter, req *http.Request) {
}
}
// handleMailMove files one message into another folder.
func (r *Router) handleMailMove(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleMove(w, req)
}
}
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
+15 -3
View File
@@ -90,7 +90,8 @@
"unknownSni": "Unknown SNI requests",
"unlockAccount": "Unlock",
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
"watcherHealthy": "Certificate watcher"
"watcherHealthy": "Certificate watcher",
"openAdminShort": "Admin"
},
"auth": {
"adminLoginTitle": "Admin console",
@@ -198,7 +199,13 @@
"undoSend": "Sending in {seconds}s",
"undoSendAction": "Undo",
"unreadCount": "{count} unread",
"unreadFilter": "Unread"
"unreadFilter": "Unread",
"ccLabel": "Cc",
"bccLabel": "Bcc",
"hideImages": "Hide images",
"alwaysFromSender": "Always from this sender",
"forgetSender": "Stop trusting sender",
"moveTo": "Move to"
},
"settings": {
"actionDiscard": "Discard",
@@ -324,6 +331,11 @@
"toggleMenu": "Toggle menu",
"totalEvaluated": "Messages evaluated",
"twoFactorSection": "Two-factor authentication",
"watching": "Watching"
"watching": "Watching",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"insertLink": "Link",
"insertImage": "Image"
}
}
+15 -3
View File
@@ -90,7 +90,8 @@
"unknownSni": "Solicitudes SNI desconocidas",
"unlockAccount": "Desbloquear",
"verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.",
"watcherHealthy": "Monitor de certificado"
"watcherHealthy": "Monitor de certificado",
"openAdminShort": "Admin"
},
"auth": {
"adminLoginTitle": "Consola de administración",
@@ -198,7 +199,13 @@
"undoSend": "Enviando en {seconds}s",
"undoSendAction": "Deshacer",
"unreadCount": "{count} sin leer",
"unreadFilter": "Sin leer"
"unreadFilter": "Sin leer",
"ccLabel": "Cc",
"bccLabel": "Cco",
"hideImages": "Ocultar imágenes",
"alwaysFromSender": "Siempre de este remitente",
"forgetSender": "Dejar de confiar",
"moveTo": "Mover a"
},
"settings": {
"actionDiscard": "Descartar",
@@ -324,6 +331,11 @@
"toggleMenu": "Alternar menú",
"totalEvaluated": "Mensajes evaluados",
"twoFactorSection": "Verificación en dos pasos",
"watching": "Vigilando"
"watching": "Vigilando",
"bold": "Negrita",
"italic": "Cursiva",
"underline": "Subrayado",
"insertLink": "Enlace",
"insertImage": "Imagen"
}
}
+15 -3
View File
@@ -90,7 +90,8 @@
"unknownSni": "Requisições SNI desconhecidas",
"unlockAccount": "Desbloquear",
"verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.",
"watcherHealthy": "Monitor de certificado"
"watcherHealthy": "Monitor de certificado",
"openAdminShort": "Admin"
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -198,7 +199,13 @@
"undoSend": "Enviando em {seconds}s",
"undoSendAction": "Desfazer",
"unreadCount": "{count} não lidas",
"unreadFilter": "Não lidas"
"unreadFilter": "Não lidas",
"ccLabel": "Cc",
"bccLabel": "Cco",
"hideImages": "Ocultar imagens",
"alwaysFromSender": "Sempre deste remetente",
"forgetSender": "Deixar de confiar",
"moveTo": "Mover para"
},
"settings": {
"actionDiscard": "Descartar",
@@ -324,6 +331,11 @@
"toggleMenu": "Alternar menu",
"totalEvaluated": "Mensagens avaliadas",
"twoFactorSection": "Verificação em duas etapas",
"watching": "Monitorando"
"watching": "Monitorando",
"bold": "Negrito",
"italic": "Itálico",
"underline": "Sublinhado",
"insertLink": "Link",
"insertImage": "Imagem"
}
}
@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { signatureToHtml } from "../../../../lib/signature";
/** Splits a comma or semicolon separated field, dropping empty entries. */
function splitAddresses(value: string): string[] {
@@ -69,8 +70,12 @@ export default function ComposePage({
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
if (editorRef.current && savedSig) {
const initial = `<br/><br/>--<br/>${savedSig}`;
// signatureToHtml, not the raw value: the signature may be the plain text
// of the old field, whose newlines are whitespace once assigned as HTML -
// which is why every line break in a signature used to vanish here.
const signatureHtml = signatureToHtml(savedSig ?? "");
if (editorRef.current && signatureHtml) {
const initial = `<br/><br/>--<br/>${signatureHtml}`;
editorRef.current.innerHTML = initial;
setBody(initial);
}
@@ -322,7 +327,7 @@ export default function ComposePage({
<>
<div className={fieldRow}>
<label htmlFor="compose-cc" className={fieldLabel}>
Cc
{t("mail.ccLabel")}
</label>
<input
id="compose-cc"
@@ -336,7 +341,7 @@ export default function ComposePage({
</div>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
Bcc
{t("mail.bccLabel")}
</label>
<input
id="compose-bcc"
@@ -167,10 +167,12 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</span>
<Link
href="/admin/step-up"
title={t("admin.openAdmin")}
aria-label={t("admin.openAdmin")}
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
>
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.openAdmin")}</span>
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
</Link>
</div>
)}
@@ -184,7 +186,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{initialsFor(account?.email)}
</span>
<span className="min-w-0 flex-1">
<span className="block break-words text-[12.5px] leading-tight">
<span
className="block truncate text-[12.5px] leading-tight"
title={account?.email ?? undefined}
>
{account?.email ?? t("common.loading")}
</span>
{/* Reports what is actually configured. The old fixed "2FA
@@ -242,7 +247,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</div>
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
{SidebarContent}
</aside>
@@ -15,6 +15,7 @@ import {
Clock,
MailOpen,
Download,
FolderInput,
Pencil,
RefreshCw,
Trash2,
@@ -30,9 +31,11 @@ import {
listHeaderCount,
applySeen,
readerActions,
moveTargets,
type ListFilter,
} from "../../../../../lib/mailCounts";
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button";
@@ -113,6 +116,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const [messages, setMessages] = useState<MessageSummary[]>([]);
const [selected, setSelected] = useState<RenderedMessage | null>(null);
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
// Whether this sender has a standing decision, so the banner can offer to
// take it back rather than only to grant it.
const [rememberSender, setRememberSender] = useState(false);
// Whether the "move to" menu is open.
const [moveOpen, setMoveOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
const [loading, setLoading] = useState(true);
@@ -245,6 +253,30 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
[folder, t, loadMessages, searchQuery]
);
/** Files one message into another folder. */
const moveMessage = useCallback(
async (uid: number, target: string) => {
setMessages((current) => current.filter((m) => m.uid !== uid));
setSelected((current) => (current?.uid === uid ? null : current));
setMoveOpen(false);
try {
const res = await fetch("/api/v1/mail/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder, uid, target }),
});
if (!res.ok) throw new Error(t("errors.serverError"));
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.serverError"));
void loadMessages(searchQuery);
} finally {
// Both folders changed, so the badges have to re-read.
notifyMailStateChanged();
}
},
[folder, t, loadMessages, searchQuery]
);
const openMessage = async (uid: number) => {
// A draft is an unfinished message: opening it means carrying on writing,
// not reading it in a pane that offers to reply to yourself.
@@ -253,13 +285,19 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
return;
}
setLoadRemoteImages(false);
setRememberSender(false);
setReaderHeight(420);
try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(t("errors.serverError"));
setSelected(await res.json());
const message = await res.json();
setSelected(message);
// A sender the reader has already allowed does not get asked again.
const trusted = isSenderTrusted(message.from ?? "");
setLoadRemoteImages(trusted);
setRememberSender(trusted);
// Fetching the message is what marks it read on the server, so this only
// brings the list and the badges into line with what already happened.
void setSeen(uid, true, { persist: false });
@@ -329,7 +367,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<div className="flex flex-1 overflow-hidden">
{/* Messages List Column */}
<div
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[404px] md:flex-none`}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[380px] md:flex-none lg:w-[420px] xl:w-[460px] 2xl:w-[500px]`}
>
{/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */}
@@ -444,15 +482,20 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</span>
</span>
{/* The subject wraps instead of being clipped - a truncated
subject is the one thing a mail list must not do. */}
subject is the one thing a mail list must not do.
overflow-wrap:anywhere as well as wrapping, because a
report's Message-ID is a single unbroken "word" longer
than the column: with nowhere to break it, it ran
straight out of the row. Clamped so one such subject
cannot push the rest of the list off the screen. */}
<span
className={`text-[13.5px] leading-snug ${
className={`line-clamp-3 text-[13.5px] leading-snug [overflow-wrap:anywhere] ${
msg.seen ? "text-slate-300" : "text-slate-100"
}`}
>
{msg.subject || t("mail.noSubject")}
</span>
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400">
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
{msg.snippet}
</span>
{msg.has_attachments && (
@@ -503,7 +546,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7"
className="mx-auto flex w-full max-w-[860px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7 xl:max-w-[1000px] 2xl:max-w-[1140px]"
>
{/* Toolbar: closing, replying and read state all in one row, so
the reader is not the only pane with no way out of itself. */}
@@ -555,6 +598,33 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
)}
{/* Filing a message elsewhere: the one ordinary action the
reader still had no button for. */}
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setMoveOpen((open) => !open)}
title={t("mail.moveTo")}
>
<FolderInput className="h-3.5 w-3.5" />
<span>{t("mail.moveTo")}</span>
</Button>
{moveOpen && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge">
{moveTargets(folders, folder).map((target) => (
<button
key={target.name}
type="button"
onClick={() => void moveMessage(selected.uid, target.name)}
className="block w-full px-3.5 py-2 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]"
>
{FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name}
</button>
))}
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
@@ -623,18 +693,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{/* Remote content banner, shown only when something was actually
held back rather than over every message. The wording is the
reassurance, not the colour: this is not an error. */}
{hasBlockedContent && (
{(hasBlockedContent || rememberSender) && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{t("mail.remoteContentBlocked")}
</span>
{/* Two separate decisions: show them now, and stop asking
about this sender. Keeping them apart lets a reader look
at one message without granting anything permanently. */}
<Button
variant="ghost"
size="sm"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
>
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
{loadRemoteImages ? t("mail.hideImages") : t("mail.loadImages")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (rememberSender) {
revokeSender(selected.from);
setRememberSender(false);
setLoadRemoteImages(false);
} else {
trustSender(selected.from);
setRememberSender(true);
setLoadRemoteImages(true);
}
}}
>
{rememberSender ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import {
Shield,
Filter,
@@ -19,6 +19,7 @@ import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
interface SieveRule {
id: string;
@@ -71,6 +72,15 @@ export default function UserSettingsPage() {
const [vacationSaved, setVacationSaved] = useState(false);
const [signature, setSignature] = useState("");
const signatureRef = useRef<HTMLDivElement>(null);
// execCommand edits the DOM directly, so the mirrored value is re-read here
// rather than relying on an input event that may not be dispatched.
const applySignatureCommand = (command: string, value?: string) => {
signatureRef.current?.focus();
document.execCommand(command, false, value);
setSignature(signatureRef.current?.innerHTML ?? "");
};
const [sigSaved, setSigSaved] = useState(false);
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
@@ -88,7 +98,11 @@ export default function UserSettingsPage() {
fetch("/api/v1/user/preferences")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.signature) setSignature(data.signature);
if (data?.signature) {
const html = signatureToHtml(data.signature);
setSignature(html);
if (signatureRef.current) signatureRef.current.innerHTML = html;
}
})
.catch(() => undefined);
@@ -107,9 +121,9 @@ export default function UserSettingsPage() {
await fetch("/api/v1/user/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signature, auto_save_drafts: true }),
body: JSON.stringify({ signature: sanitizeSignature(signature), auto_save_drafts: true }),
});
localStorage.setItem("lm_user_signature", signature);
localStorage.setItem("lm_user_signature", sanitizeSignature(signature));
setSigSaved(true);
setTimeout(() => setSigSaved(false), 2000);
} catch {
@@ -748,13 +762,69 @@ export default function UserSettingsPage() {
<label htmlFor="signature-text" className={fieldLabel}>
{t("ui.messageBody")}
</label>
<textarea
{/* A rich field rather than a textarea. A signature is a piece
of a message - people put a link, a logo and bold type in
it - and the plain textarea could hold none of that. It
also stored real newlines, which are whitespace once the
value is injected into the composer as HTML, so every line
break was silently dropped there. */}
<div className="flex flex-wrap items-center gap-1 rounded-t-xl bg-dark-card px-2 py-1.5 shadow-edge">
{[
{ cmd: "bold", label: "B", title: t("ui.bold"), cls: "font-bold" },
{ cmd: "italic", label: "I", title: t("ui.italic"), cls: "italic" },
{ cmd: "underline", label: "U", title: t("ui.underline"), cls: "underline" },
].map((b) => (
<button
key={b.cmd}
type="button"
title={b.title}
aria-label={b.title}
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySignatureCommand(b.cmd)}
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
>
{b.label}
</button>
))}
<span className="mx-1 h-4 w-px bg-white/10" />
<button
type="button"
title={t("ui.insertLink")}
aria-label={t("ui.insertLink")}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const url = window.prompt(t("ui.insertLink"), "https://");
if (url) applySignatureCommand("createLink", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
>
{t("ui.insertLink")}
</button>
<button
type="button"
title={t("ui.insertImage")}
aria-label={t("ui.insertImage")}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const url = window.prompt(t("ui.insertImage"), "https://");
if (url) applySignatureCommand("insertImage", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
>
{t("ui.insertImage")}
</button>
</div>
<div
id="signature-text"
rows={7}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder={t("settings.signaturePlaceholder")}
className={`${input} leading-relaxed`}
ref={signatureRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={t("settings.signatureTitle")}
onInput={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
onBlur={(e) => setSignature((e.target as HTMLDivElement).innerHTML)}
className="min-h-[170px] rounded-b-xl bg-dark-panel px-3.5 py-3 text-[13.5px] leading-relaxed text-slate-200 shadow-edge focus:outline-none"
/>
</div>
+28
View File
@@ -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);
});
}
+88
View File
@@ -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));
}
+104
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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>");
}
+47 -1
View File
@@ -159,7 +159,7 @@ describe("marking a message read in the loaded list", () => {
// --- what the sidebar actually renders -----------------------------------
import { folderBadge, readerActions } from "./lib/mailCounts";
import { folderBadge, readerActions, moveTargets } from "./lib/mailCounts";
/**
* These reproduce what the running webmail shows, which the badgeCount tests
@@ -256,3 +256,49 @@ describe("the actions a message offers", () => {
expect(readerActions("inbox").deleteIsPermanent).toBe(false);
});
});
describe("where a message may be moved", () => {
const folders = [
{ special_use: "inbox", name: "INBOX", unread_count: 0, total_count: 3 },
{ special_use: "sent", name: "Sent", unread_count: 0, total_count: 1 },
{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 1 },
{ special_use: "archive", name: "Archive", unread_count: 0, total_count: 0 },
{ special_use: "junk", name: "Junk", unread_count: 0, total_count: 0 },
{ special_use: "trash", name: "Trash", unread_count: 0, total_count: 0 },
{ special_use: "", name: "Reports", unread_count: 0, total_count: 2 },
{ special_use: "", name: "Faturas", unread_count: 0, total_count: 0 },
];
// Sent and Drafts record how a message came to exist, not where it was
// filed. Offering them would let Sent claim a message it never sent.
it("never offers Sent or Drafts", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).not.toContain("Sent");
expect(names).not.toContain("Drafts");
});
it("does not offer the folder the message is already in", () => {
expect(moveTargets(folders, "inbox").map((f) => f.name)).not.toContain("INBOX");
expect(moveTargets(folders, "archive").map((f) => f.name)).not.toContain("Archive");
});
it("offers the ordinary destinations", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).toContain("Archive");
expect(names).toContain("Junk");
expect(names).toContain("Trash");
});
// A folder the user made themselves has no special-use role, and matching
// only on roles would leave it unreachable.
it("offers folders the user created", () => {
const names = moveTargets(folders, "inbox").map((f) => f.name);
expect(names).toContain("Reports");
expect(names).toContain("Faturas");
});
it("can move out of Drafts and Sent, even though nothing moves in", () => {
expect(moveTargets(folders, "drafts").map((f) => f.name)).toContain("Archive");
expect(moveTargets(folders, "sent").map((f) => f.name)).toContain("Archive");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
senderKey,
isSenderTrusted,
trustSender,
revokeSender,
TRUSTED_SENDERS_KEY,
} from "./lib/remoteImages";
/**
* Whether a message's remote images load.
*
* The reader reset the choice every time a message was opened, so the same
* newsletter had to be unblocked again on every single read - and the button
* gave no way to say "stop asking me about this sender".
*
* Blocked stays the default: remote images are how a sender learns that a
* message was opened, and when. Only an explicit decision changes it.
*/
beforeEach(() => localStorage.clear());
describe("identifying the sender", () => {
it("reads the address out of a display-name form", () => {
expect(senderKey('Jenny <jenny@example.test>')).toBe("jenny@example.test");
});
it("ignores case, so one decision covers the sender either way", () => {
expect(senderKey("Jenny@Example.TEST")).toBe(senderKey("jenny@example.test"));
});
it("returns empty for something that is not an address", () => {
expect(senderKey("")).toBe("");
expect(senderKey(" ")).toBe("");
});
});
describe("remembering the decision", () => {
it("blocks by default, so opening a message never phones home on its own", () => {
expect(isSenderTrusted("jenny@example.test")).toBe(false);
});
it("keeps loading images from a sender once allowed", () => {
trustSender("Jenny <jenny@example.test>");
expect(isSenderTrusted("jenny@example.test")).toBe(true);
// And on a different message from the same sender.
expect(isSenderTrusted("JENNY@EXAMPLE.TEST")).toBe(true);
});
it("can be taken back", () => {
trustSender("jenny@example.test");
revokeSender("jenny@example.test");
expect(isSenderTrusted("jenny@example.test")).toBe(false);
});
it("keeps senders apart", () => {
trustSender("jenny@example.test");
expect(isSenderTrusted("someone@else.test")).toBe(false);
});
it("survives a reload, which is the whole point", () => {
trustSender("jenny@example.test");
const stored = localStorage.getItem(TRUSTED_SENDERS_KEY);
expect(stored).toContain("jenny@example.test");
});
it("does not grow without bound as senders accumulate", () => {
for (let i = 0; i < 500; i++) trustSender(`s${i}@example.test`);
const stored = JSON.parse(localStorage.getItem(TRUSTED_SENDERS_KEY) ?? "[]");
expect(stored.length).toBeLessThanOrEqual(200);
// The most recent decision is the one that must survive the trim.
expect(isSenderTrusted("s499@example.test")).toBe(true);
});
// A private window, cleared site data, or a browser that refuses storage:
// the reader must still open, just without remembering.
it("degrades to blocked when storage is unavailable", () => {
const original = Object.getOwnPropertyDescriptor(window, "localStorage");
Object.defineProperty(window, "localStorage", {
configurable: true,
get() {
throw new Error("storage disabled");
},
});
expect(() => isSenderTrusted("jenny@example.test")).not.toThrow();
expect(isSenderTrusted("jenny@example.test")).toBe(false);
expect(() => trustSender("jenny@example.test")).not.toThrow();
if (original) Object.defineProperty(window, "localStorage", original);
});
it("ignores corrupted storage rather than failing to render", () => {
localStorage.setItem(TRUSTED_SENDERS_KEY, "{not json");
expect(isSenderTrusted("jenny@example.test")).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
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("&lt;lucas@example.test&gt;");
expect(html).toContain("&amp;");
});
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("&lt;p&gt;");
});
});
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("");
});
});