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
@@ -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.
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user