fix(webmail): acertos de interface e mover mensagens entre pastas

Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês
que restava, e "Bcc" tem nome próprio em português - quem não conhece
a abreviatura inglesa não adivinha o que o campo faz.

Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console
de administração", que transbordava no telemóvel e no computador: o
botão passa a mostrar um rótulo curto e guarda o nome completo no
tooltip. O endereço da conta usava break-words e partia-se a meio do
domínio, deixando um "br" sozinho na linha de baixo; passa a truncar
com o valor inteiro no tooltip.

A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã
largo os assuntos eram espremidos numa coluna estreita enquanto o
leitor ficava entre duas margens enormes. Ambos crescem agora com a
janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do
Google são uma única "palavra" sem espaços, mais longa que a coluna, e
sem sítio onde quebrar saíam da linha.

Imagens remotas. A escolha era reposta sempre que uma mensagem era
aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada
leitura. A decisão passa a ser lembrada por remetente, com um segundo
botão para a retirar. Bloqueado continua a ser o que acontece por
omissão: uma imagem remota é um aviso de leitura que o remetente não
pediu licença para receber.

Assinatura. Era escrita num textarea - texto simples com quebras de
linha reais - e injetada no compositor com innerHTML, onde uma quebra
de linha é espaço em branco: todas desapareciam. Passa a ser um campo
rico, com negrito, itálico, links e imagens, guardado como HTML e
sanitizado nos dois sentidos, já que este valor é injetado como
marcação na origem do webmail. Assinaturas guardadas pelo campo antigo
continuam a funcionar: as suas quebras de linha são convertidas.

Mover entre pastas. Faltava a única ação corrente que o leitor não
tinha: arquivar uma mensagem para fora da caixa de entrada era
impossível. Enviados e Rascunhos nunca são destino - descrevem como a
mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é
permitido. Pastas criadas pelo utilizador, que não têm papel
special-use, são destino como qualquer outra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-22 13:18:08 -03:00
1 parent ac0521cb9b
commit f179cb1f27
20 files changed
+911 -37

No files matched your search

@@ -64,6 +64,10 @@ type WebmailRepository interface {
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
// Expunge soft-deletes one message, keeping the folder counters in step.
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
// MoveToFolder relocates one message into a named folder and returns its
// new UID there. A UID is meaningful only inside one folder, so the
// message keeps its identity and its blob but is renumbered.
MoveToFolder(ctx context.Context, mailboxID, folderName string, uid uint32, target string) (uint32, error)
// MoveToTrash relocates one message into Trash and returns its new UID
// there. This is what deleting a message means everywhere but in Trash
// itself, where Expunge is the operation instead.
@@ -2,6 +2,8 @@ package usecase
import (
"context"
"errors"
"strconv"
"strings"
"testing"
@@ -126,6 +128,7 @@ type stubWebmailRepo struct {
expungeErr error
movedToTrash []uint32
moveErr error
moved []string
}
func (s *stubWebmailRepo) FindMailboxIDByAddress(context.Context, string) (string, error) {
@@ -145,6 +148,16 @@ func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) er
s.expunged = append(s.expunged, uid)
return s.expungeErr
}
func (s *stubWebmailRepo) MoveToFolder(
_ context.Context, _, from string, uid uint32, to string,
) (uint32, error) {
if s.moveErr != nil {
return 0, s.moveErr
}
s.moved = append(s.moved, from+"->"+to+"/"+strconv.FormatUint(uint64(uid), 10))
return uid, nil
}
func (s *stubWebmailRepo) MoveToTrash(_ context.Context, _, _ string, uid uint32) (uint32, error) {
if s.moveErr != nil {
return 0, s.moveErr
@@ -372,3 +385,79 @@ func TestDeleteFromTrashExpunges(t *testing.T) {
t.Errorf("emptying from Trash did not remove the message: %v", repo.expunged)
}
}
// --- moving between folders ---------------------------------------------
// Filing a message somewhere else is the one ordinary mail action the webmail
// still had no way to perform.
func TestMoveFilesTheMessageInTheTargetFolder(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "Archive"); err != nil {
t.Fatalf("move: %v", err)
}
if len(repo.moved) != 1 || repo.moved[0] != "inbox->Archive/5" {
t.Errorf("moved %v", repo.moved)
}
}
// Sent and Drafts describe how a message came to exist, not where the reader
// filed it. Moving arbitrary mail into them would make the folder lie: Sent
// would hold messages that were never sent, and a draft that cannot be edited
// would sit among the unfinished ones.
func TestMoveRefusesSentAndDrafts(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
for _, target := range []string{"Sent", "sent", "Drafts", "DRAFTS"} {
err := uc.Move(context.Background(), "me@example.test", "inbox", 5, target)
if !errors.Is(err, ErrFolderNotATarget) {
t.Errorf("moving into %q returned %v, want ErrFolderNotATarget", target, err)
}
}
if len(repo.moved) != 0 {
t.Errorf("a refused move still touched the repository: %v", repo.moved)
}
}
// Moving a message into the folder it is already in is a no-op, not an error:
// it is what a double click on the same target produces.
func TestMoveIntoTheSameFolderDoesNothing(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, "inbox"); err != nil {
t.Fatalf("move into the same folder: %v", err)
}
if len(repo.moved) != 0 {
t.Errorf("a no-op move still touched the repository: %v", repo.moved)
}
}
func TestMoveRejectsAnEmptyTarget(t *testing.T) {
mailboxID := uuid.New()
uc := &WebmailUseCase{
repo: &stubWebmailRepo{mailboxID: mailboxID.String()},
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.Move(context.Background(), "me@example.test", "inbox", 5, " "); err == nil {
t.Error("an empty target should be refused")
}
}
@@ -207,6 +207,47 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
return nil
}
// ErrFolderNotATarget refuses a move into a folder that describes how a
// message came to exist rather than where its reader filed it.
var ErrFolderNotATarget = errors.New("webmail: messages cannot be moved into this folder")
// ErrNoTargetFolder rejects a move with no destination named.
var ErrNoTargetFolder = errors.New("webmail: a destination folder is required")
// notAMoveTarget are the folders a message may never be moved into.
//
// Sent and Drafts are not places a reader files things; they record what a
// message is. Moving arbitrary mail into Sent would make it claim the message
// was sent from here, and a message in Drafts that cannot be edited would sit
// among the unfinished ones forever.
var notAMoveTarget = map[string]bool{"sent": true, "drafts": true}
// Move files one message into another folder.
//
// The webmail could delete a message and it could read one, but it had no way
// to put it anywhere: archiving something out of the inbox was impossible.
func (uc *WebmailUseCase) Move(ctx context.Context, address, folder string, uid uint32, target string) error {
target = strings.TrimSpace(target)
if target == "" {
return ErrNoTargetFolder
}
if notAMoveTarget[strings.ToLower(target)] {
return ErrFolderNotATarget
}
// Moving a message where it already is is what a second click on the same
// target produces; it is a no-op rather than an error.
if strings.EqualFold(target, folder) {
return nil
}
mailboxID, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
_, err = uc.repo.MoveToFolder(ctx, mailboxID, folder, uid, target)
return err
}
// Delete removes one message the way a mail client does: from any ordinary
// folder it goes to Trash, and from Trash it is really expunged.
//
@@ -299,6 +299,28 @@ func (r *WebmailRepository) Expunge(
// Trash itself is Expunge's job, not this one's.
func (r *WebmailRepository) MoveToTrash(
ctx context.Context, mailboxID, folderName string, uid uint32,
) (uint32, error) {
return r.moveInto(ctx, mailboxID, folderName, uid, trashTarget)
}
// MoveToFolder files one message into a named folder.
//
// The target is matched on its special-use role or its name, the same way the
// rest of this repository addresses folders, so "archive" and "Archive" both
// reach the same place and a folder the user created themselves - which has no
// role - is reachable by name.
func (r *WebmailRepository) MoveToFolder(
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
) (uint32, error) {
return r.moveInto(ctx, mailboxID, folderName, uid, target)
}
// trashTarget is what MoveToTrash resolves; kept as a constant so the two
// callers cannot drift.
const trashTarget = "trash"
func (r *WebmailRepository) moveInto(
ctx context.Context, mailboxID, folderName string, uid uint32, target string,
) (uint32, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
@@ -333,11 +355,11 @@ func (r *WebmailRepository) MoveToTrash(
var trashUID int64
err = tx.QueryRow(ctx, `
SELECT id::text, uid_next FROM folders
WHERE mailbox_id = $1 AND (special_use = 'trash' OR LOWER(name) = 'trash')
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
ORDER BY special_use IS NOT NULL DESC
LIMIT 1
FOR UPDATE
`, mailboxID).Scan(&trashFolderID, &trashUID)
`, mailboxID, target).Scan(&trashFolderID, &trashUID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, port.ErrNoTrashFolder
}
@@ -12,6 +12,7 @@ import (
"github.com/emersion/go-message"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/postgres"
)
@@ -290,6 +291,37 @@ func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleMove files one message into another folder.
func (m *mailAPI) handleMove(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
var body struct {
Folder string `json:"folder"`
UID uint32 `json:"uid"`
Target string `json:"target"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
if body.Folder == "" {
body.Folder = "inbox"
}
if body.UID == 0 {
writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID is required")
return
}
if err := m.useCase.Move(r.Context(), session.Email, body.Folder, body.UID, body.Target); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"moved": true})
}
// handleSaveDraft stores the message being composed in the Drafts folder.
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
@@ -370,6 +402,13 @@ func (m *mailAPI) fail(w http.ResponseWriter, err error) {
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
case errors.Is(err, usecase.ErrFolderNotATarget):
writeError(w, http.StatusBadRequest, "FOLDER_NOT_A_TARGET",
"Messages cannot be moved into Sent or Drafts")
case errors.Is(err, usecase.ErrNoTargetFolder):
writeError(w, http.StatusBadRequest, "NO_TARGET_FOLDER", "A destination folder is required")
case errors.Is(err, port.ErrNoTrashFolder):
writeError(w, http.StatusNotFound, "NOT_FOUND", "That folder does not exist")
case errors.Is(err, usecase.ErrDraftsUnavailable):
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
default:
@@ -89,6 +89,16 @@ func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid
return nil
}
func (f *fakeMailStore) MoveToFolder(
_ context.Context, mailboxID, folder string, uid uint32, target string,
) (uint32, error) {
if f.moveErr != nil {
return 0, f.moveErr
}
f.movedCalls = append(f.movedCalls, mailboxID+"/"+folder+"->"+target)
return uid, nil
}
func (f *fakeMailStore) MoveToTrash(
_ context.Context, mailboxID, folder string, uid uint32,
) (uint32, error) {
@@ -90,6 +90,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
r.mux.HandleFunc("/api/v1/mail/move", r.handleMailMove)
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
}
@@ -316,6 +317,17 @@ func (r *Router) handleMailDelete(w http.ResponseWriter, req *http.Request) {
}
}
// handleMailMove files one message into another folder.
func (r *Router) handleMailMove(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleMove(w, req)
}
}
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")