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
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("<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("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user