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
|
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
|
||||||
// Expunge soft-deletes one message, keeping the folder counters in step.
|
// Expunge soft-deletes one message, keeping the folder counters in step.
|
||||||
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
|
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
|
// MoveToTrash relocates one message into Trash and returns its new UID
|
||||||
// there. This is what deleting a message means everywhere but in Trash
|
// there. This is what deleting a message means everywhere but in Trash
|
||||||
// itself, where Expunge is the operation instead.
|
// itself, where Expunge is the operation instead.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package usecase
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ type stubWebmailRepo struct {
|
|||||||
expungeErr error
|
expungeErr error
|
||||||
movedToTrash []uint32
|
movedToTrash []uint32
|
||||||
moveErr error
|
moveErr error
|
||||||
|
moved []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubWebmailRepo) FindMailboxIDByAddress(context.Context, string) (string, error) {
|
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)
|
s.expunged = append(s.expunged, uid)
|
||||||
return s.expungeErr
|
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) {
|
func (s *stubWebmailRepo) MoveToTrash(_ context.Context, _, _ string, uid uint32) (uint32, error) {
|
||||||
if s.moveErr != nil {
|
if s.moveErr != nil {
|
||||||
return 0, s.moveErr
|
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)
|
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
|
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
|
// 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.
|
// 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.
|
// Trash itself is Expunge's job, not this one's.
|
||||||
func (r *WebmailRepository) MoveToTrash(
|
func (r *WebmailRepository) MoveToTrash(
|
||||||
ctx context.Context, mailboxID, folderName string, uid uint32,
|
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) {
|
) (uint32, error) {
|
||||||
tx, err := r.pool.Begin(ctx)
|
tx, err := r.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -333,11 +355,11 @@ func (r *WebmailRepository) MoveToTrash(
|
|||||||
var trashUID int64
|
var trashUID int64
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
SELECT id::text, uid_next FROM folders
|
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
|
ORDER BY special_use IS NOT NULL DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`, mailboxID).Scan(&trashFolderID, &trashUID)
|
`, mailboxID, target).Scan(&trashFolderID, &trashUID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return 0, port.ErrNoTrashFolder
|
return 0, port.ErrNoTrashFolder
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/emersion/go-message"
|
"github.com/emersion/go-message"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
"lambdamail/protocols/internal/application/usecase"
|
"lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"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})
|
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.
|
// handleSaveDraft stores the message being composed in the Drafts folder.
|
||||||
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
session, ok := m.authenticate(w, r)
|
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")
|
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
|
||||||
case errors.Is(err, usecase.ErrSenderNotOwned):
|
case errors.Is(err, usecase.ErrSenderNotOwned):
|
||||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
|
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):
|
case errors.Is(err, usecase.ErrDraftsUnavailable):
|
||||||
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
|
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid
|
|||||||
return nil
|
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(
|
func (f *fakeMailStore) MoveToTrash(
|
||||||
_ context.Context, mailboxID, folder string, uid uint32,
|
_ context.Context, mailboxID, folder string, uid uint32,
|
||||||
) (uint32, error) {
|
) (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/send", r.handleMailSend)
|
||||||
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
|
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
|
||||||
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
|
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/dmarc", r.handleDmarcIngest)
|
||||||
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
|
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) {
|
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
||||||
|
|||||||
@@ -90,7 +90,8 @@
|
|||||||
"unknownSni": "Unknown SNI requests",
|
"unknownSni": "Unknown SNI requests",
|
||||||
"unlockAccount": "Unlock",
|
"unlockAccount": "Unlock",
|
||||||
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
|
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
|
||||||
"watcherHealthy": "Certificate watcher"
|
"watcherHealthy": "Certificate watcher",
|
||||||
|
"openAdminShort": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"adminLoginTitle": "Admin console",
|
"adminLoginTitle": "Admin console",
|
||||||
@@ -198,7 +199,13 @@
|
|||||||
"undoSend": "Sending in {seconds}s",
|
"undoSend": "Sending in {seconds}s",
|
||||||
"undoSendAction": "Undo",
|
"undoSendAction": "Undo",
|
||||||
"unreadCount": "{count} unread",
|
"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": {
|
"settings": {
|
||||||
"actionDiscard": "Discard",
|
"actionDiscard": "Discard",
|
||||||
@@ -324,6 +331,11 @@
|
|||||||
"toggleMenu": "Toggle menu",
|
"toggleMenu": "Toggle menu",
|
||||||
"totalEvaluated": "Messages evaluated",
|
"totalEvaluated": "Messages evaluated",
|
||||||
"twoFactorSection": "Two-factor authentication",
|
"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",
|
"unknownSni": "Solicitudes SNI desconocidas",
|
||||||
"unlockAccount": "Desbloquear",
|
"unlockAccount": "Desbloquear",
|
||||||
"verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.",
|
"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": {
|
"auth": {
|
||||||
"adminLoginTitle": "Consola de administración",
|
"adminLoginTitle": "Consola de administración",
|
||||||
@@ -198,7 +199,13 @@
|
|||||||
"undoSend": "Enviando en {seconds}s",
|
"undoSend": "Enviando en {seconds}s",
|
||||||
"undoSendAction": "Deshacer",
|
"undoSendAction": "Deshacer",
|
||||||
"unreadCount": "{count} sin leer",
|
"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": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
@@ -324,6 +331,11 @@
|
|||||||
"toggleMenu": "Alternar menú",
|
"toggleMenu": "Alternar menú",
|
||||||
"totalEvaluated": "Mensajes evaluados",
|
"totalEvaluated": "Mensajes evaluados",
|
||||||
"twoFactorSection": "Verificación en dos pasos",
|
"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",
|
"unknownSni": "Requisições SNI desconhecidas",
|
||||||
"unlockAccount": "Desbloquear",
|
"unlockAccount": "Desbloquear",
|
||||||
"verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.",
|
"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": {
|
"auth": {
|
||||||
"adminLoginTitle": "Console de administração",
|
"adminLoginTitle": "Console de administração",
|
||||||
@@ -198,7 +199,13 @@
|
|||||||
"undoSend": "Enviando em {seconds}s",
|
"undoSend": "Enviando em {seconds}s",
|
||||||
"undoSendAction": "Desfazer",
|
"undoSendAction": "Desfazer",
|
||||||
"unreadCount": "{count} não lidas",
|
"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": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
@@ -324,6 +331,11 @@
|
|||||||
"toggleMenu": "Alternar menu",
|
"toggleMenu": "Alternar menu",
|
||||||
"totalEvaluated": "Mensagens avaliadas",
|
"totalEvaluated": "Mensagens avaliadas",
|
||||||
"twoFactorSection": "Verificação em duas etapas",
|
"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";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
import { signatureToHtml } from "../../../../lib/signature";
|
||||||
|
|
||||||
/** Splits a comma or semicolon separated field, dropping empty entries. */
|
/** Splits a comma or semicolon separated field, dropping empty entries. */
|
||||||
function splitAddresses(value: string): string[] {
|
function splitAddresses(value: string): string[] {
|
||||||
@@ -69,8 +70,12 @@ export default function ComposePage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedSig = localStorage.getItem("lm_user_signature");
|
const savedSig = localStorage.getItem("lm_user_signature");
|
||||||
if (editorRef.current && savedSig) {
|
// signatureToHtml, not the raw value: the signature may be the plain text
|
||||||
const initial = `<br/><br/>--<br/>${savedSig}`;
|
// 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;
|
editorRef.current.innerHTML = initial;
|
||||||
setBody(initial);
|
setBody(initial);
|
||||||
}
|
}
|
||||||
@@ -322,7 +327,7 @@ export default function ComposePage({
|
|||||||
<>
|
<>
|
||||||
<div className={fieldRow}>
|
<div className={fieldRow}>
|
||||||
<label htmlFor="compose-cc" className={fieldLabel}>
|
<label htmlFor="compose-cc" className={fieldLabel}>
|
||||||
Cc
|
{t("mail.ccLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="compose-cc"
|
id="compose-cc"
|
||||||
@@ -336,7 +341,7 @@ export default function ComposePage({
|
|||||||
</div>
|
</div>
|
||||||
<div className={fieldRow}>
|
<div className={fieldRow}>
|
||||||
<label htmlFor="compose-bcc" className={fieldLabel}>
|
<label htmlFor="compose-bcc" className={fieldLabel}>
|
||||||
Bcc
|
{t("mail.bccLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="compose-bcc"
|
id="compose-bcc"
|
||||||
|
|||||||
@@ -167,10 +167,12 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/step-up"
|
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"
|
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" />
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +186,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
{initialsFor(account?.email)}
|
{initialsFor(account?.email)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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")}
|
{account?.email ?? t("common.loading")}
|
||||||
</span>
|
</span>
|
||||||
{/* Reports what is actually configured. The old fixed "2FA
|
{/* Reports what is actually configured. The old fixed "2FA
|
||||||
@@ -242,7 +247,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop rail */}
|
{/* 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}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Download,
|
Download,
|
||||||
|
FolderInput,
|
||||||
Pencil,
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -30,9 +31,11 @@ import {
|
|||||||
listHeaderCount,
|
listHeaderCount,
|
||||||
applySeen,
|
applySeen,
|
||||||
readerActions,
|
readerActions,
|
||||||
|
moveTargets,
|
||||||
type ListFilter,
|
type ListFilter,
|
||||||
} from "../../../../../lib/mailCounts";
|
} from "../../../../../lib/mailCounts";
|
||||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
||||||
|
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
||||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||||
import { Button } from "../../../../../components/ui/Button";
|
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 [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -245,6 +253,30 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
[folder, t, loadMessages, searchQuery]
|
[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) => {
|
const openMessage = async (uid: number) => {
|
||||||
// A draft is an unfinished message: opening it means carrying on writing,
|
// A draft is an unfinished message: opening it means carrying on writing,
|
||||||
// not reading it in a pane that offers to reply to yourself.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
|
setRememberSender(false);
|
||||||
setReaderHeight(420);
|
setReaderHeight(420);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
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
|
// 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.
|
// brings the list and the badges into line with what already happened.
|
||||||
void setSeen(uid, true, { persist: false });
|
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">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Messages List Column */}
|
{/* Messages List Column */}
|
||||||
<div
|
<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
|
{/* Folder heading, with the counts that belong to the folder rather
|
||||||
than to the page of it that happens to be loaded. */}
|
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>
|
||||||
</span>
|
</span>
|
||||||
{/* The subject wraps instead of being clipped - a truncated
|
{/* 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
|
<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.seen ? "text-slate-300" : "text-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.subject || t("mail.noSubject")}
|
{msg.subject || t("mail.noSubject")}
|
||||||
</span>
|
</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}
|
{msg.snippet}
|
||||||
</span>
|
</span>
|
||||||
{msg.has_attachments && (
|
{msg.has_attachments && (
|
||||||
@@ -503,7 +546,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
exit={{ opacity: 0, y: -8 }}
|
||||||
transition={{ duration: 0.15 }}
|
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
|
{/* Toolbar: closing, replying and read state all in one row, so
|
||||||
the reader is not the only pane with no way out of itself. */}
|
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>
|
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -623,18 +693,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{/* Remote content banner, shown only when something was actually
|
{/* Remote content banner, shown only when something was actually
|
||||||
held back rather than over every message. The wording is the
|
held back rather than over every message. The wording is the
|
||||||
reassurance, not the colour: this is not an error. */}
|
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">
|
<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" />
|
<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">
|
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||||
{t("mail.remoteContentBlocked")}
|
{t("mail.remoteContentBlocked")}
|
||||||
</span>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -19,6 +19,7 @@ import { Badge } from "../../../../components/ui/Badge";
|
|||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||||
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
|
||||||
|
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
|
||||||
|
|
||||||
interface SieveRule {
|
interface SieveRule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,6 +72,15 @@ export default function UserSettingsPage() {
|
|||||||
const [vacationSaved, setVacationSaved] = useState(false);
|
const [vacationSaved, setVacationSaved] = useState(false);
|
||||||
|
|
||||||
const [signature, setSignature] = useState("");
|
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);
|
const [sigSaved, setSigSaved] = useState(false);
|
||||||
|
|
||||||
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
|
// 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")
|
fetch("/api/v1/user/preferences")
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data) => {
|
.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);
|
.catch(() => undefined);
|
||||||
|
|
||||||
@@ -107,9 +121,9 @@ export default function UserSettingsPage() {
|
|||||||
await fetch("/api/v1/user/preferences", {
|
await fetch("/api/v1/user/preferences", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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);
|
setSigSaved(true);
|
||||||
setTimeout(() => setSigSaved(false), 2000);
|
setTimeout(() => setSigSaved(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -748,13 +762,69 @@ export default function UserSettingsPage() {
|
|||||||
<label htmlFor="signature-text" className={fieldLabel}>
|
<label htmlFor="signature-text" className={fieldLabel}>
|
||||||
{t("ui.messageBody")}
|
{t("ui.messageBody")}
|
||||||
</label>
|
</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"
|
id="signature-text"
|
||||||
rows={7}
|
ref={signatureRef}
|
||||||
value={signature}
|
contentEditable
|
||||||
onChange={(e) => setSignature(e.target.value)}
|
suppressContentEditableWarning
|
||||||
placeholder={t("settings.signaturePlaceholder")}
|
role="textbox"
|
||||||
className={`${input} leading-relaxed`}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -194,3 +194,31 @@ export function readerActions(folder: string): ReaderActions {
|
|||||||
deleteIsPermanent: role === "trash",
|
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 -----------------------------------
|
// --- 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
|
* 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);
|
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