From f179cb1f277441571a012e0aab9f66c766b09379 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 22 Aug 2026 13:18:08 -0300 Subject: [PATCH] fix(webmail): acertos de interface e mover mensagens entre pastas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../application/port/webmail_repository.go | 4 + .../usecase/webmail_compose_test.go | 89 +++++++++++++++ .../application/usecase/webmail_mail.go | 41 +++++++ .../postgres/webmail_repository.go | 26 ++++- .../internal/presentation/http/mail_api.go | 39 +++++++ .../presentation/http/mail_api_test.go | 10 ++ .../internal/presentation/http/router.go | 12 ++ services/webmail/messages/en.json | 18 ++- services/webmail/messages/es.json | 18 ++- services/webmail/messages/pt-BR.json | 18 ++- .../src/app/(user)/user/compose/page.tsx | 13 ++- .../webmail/src/app/(user)/user/layout.tsx | 11 +- .../app/(user)/user/mail/[folder]/page.tsx | 106 ++++++++++++++++-- .../src/app/(user)/user/settings/page.tsx | 90 +++++++++++++-- services/webmail/src/lib/mailCounts.ts | 28 +++++ services/webmail/src/lib/remoteImages.ts | 88 +++++++++++++++ services/webmail/src/lib/signature.ts | 104 +++++++++++++++++ services/webmail/src/mailCounts.test.ts | 48 +++++++- services/webmail/src/remoteImages.test.ts | 94 ++++++++++++++++ services/webmail/src/signature.test.ts | 91 +++++++++++++++ 20 files changed, 911 insertions(+), 37 deletions(-) create mode 100644 services/webmail/src/lib/remoteImages.ts create mode 100644 services/webmail/src/lib/signature.ts create mode 100644 services/webmail/src/remoteImages.test.ts create mode 100644 services/webmail/src/signature.test.ts diff --git a/services/protocols/internal/application/port/webmail_repository.go b/services/protocols/internal/application/port/webmail_repository.go index 344053c..b6c106e 100644 --- a/services/protocols/internal/application/port/webmail_repository.go +++ b/services/protocols/internal/application/port/webmail_repository.go @@ -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. diff --git a/services/protocols/internal/application/usecase/webmail_compose_test.go b/services/protocols/internal/application/usecase/webmail_compose_test.go index 933e1fe..0da4f61 100644 --- a/services/protocols/internal/application/usecase/webmail_compose_test.go +++ b/services/protocols/internal/application/usecase/webmail_compose_test.go @@ -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") + } +} diff --git a/services/protocols/internal/application/usecase/webmail_mail.go b/services/protocols/internal/application/usecase/webmail_mail.go index 4f7f2d7..76ec21b 100644 --- a/services/protocols/internal/application/usecase/webmail_mail.go +++ b/services/protocols/internal/application/usecase/webmail_mail.go @@ -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. // diff --git a/services/protocols/internal/infrastructure/postgres/webmail_repository.go b/services/protocols/internal/infrastructure/postgres/webmail_repository.go index 8c054d9..3d502f9 100644 --- a/services/protocols/internal/infrastructure/postgres/webmail_repository.go +++ b/services/protocols/internal/infrastructure/postgres/webmail_repository.go @@ -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 } diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go index f595e64..8e1a75f 100644 --- a/services/protocols/internal/presentation/http/mail_api.go +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -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: diff --git a/services/protocols/internal/presentation/http/mail_api_test.go b/services/protocols/internal/presentation/http/mail_api_test.go index 2f045f5..a4a2a78 100644 --- a/services/protocols/internal/presentation/http/mail_api_test.go +++ b/services/protocols/internal/presentation/http/mail_api_test.go @@ -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) { diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index c7bf01d..5c15b01 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -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") diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 0916446..36c9bf4 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -90,7 +90,8 @@ "unknownSni": "Unknown SNI requests", "unlockAccount": "Unlock", "verifyIntro": "Resolves every expected record against public resolvers and reports each one.", - "watcherHealthy": "Certificate watcher" + "watcherHealthy": "Certificate watcher", + "openAdminShort": "Admin" }, "auth": { "adminLoginTitle": "Admin console", @@ -198,7 +199,13 @@ "undoSend": "Sending in {seconds}s", "undoSendAction": "Undo", "unreadCount": "{count} unread", - "unreadFilter": "Unread" + "unreadFilter": "Unread", + "ccLabel": "Cc", + "bccLabel": "Bcc", + "hideImages": "Hide images", + "alwaysFromSender": "Always from this sender", + "forgetSender": "Stop trusting sender", + "moveTo": "Move to" }, "settings": { "actionDiscard": "Discard", @@ -324,6 +331,11 @@ "toggleMenu": "Toggle menu", "totalEvaluated": "Messages evaluated", "twoFactorSection": "Two-factor authentication", - "watching": "Watching" + "watching": "Watching", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "insertLink": "Link", + "insertImage": "Image" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index d83e5fe..5f42b1d 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -90,7 +90,8 @@ "unknownSni": "Solicitudes SNI desconocidas", "unlockAccount": "Desbloquear", "verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.", - "watcherHealthy": "Monitor de certificado" + "watcherHealthy": "Monitor de certificado", + "openAdminShort": "Admin" }, "auth": { "adminLoginTitle": "Consola de administración", @@ -198,7 +199,13 @@ "undoSend": "Enviando en {seconds}s", "undoSendAction": "Deshacer", "unreadCount": "{count} sin leer", - "unreadFilter": "Sin leer" + "unreadFilter": "Sin leer", + "ccLabel": "Cc", + "bccLabel": "Cco", + "hideImages": "Ocultar imágenes", + "alwaysFromSender": "Siempre de este remitente", + "forgetSender": "Dejar de confiar", + "moveTo": "Mover a" }, "settings": { "actionDiscard": "Descartar", @@ -324,6 +331,11 @@ "toggleMenu": "Alternar menú", "totalEvaluated": "Mensajes evaluados", "twoFactorSection": "Verificación en dos pasos", - "watching": "Vigilando" + "watching": "Vigilando", + "bold": "Negrita", + "italic": "Cursiva", + "underline": "Subrayado", + "insertLink": "Enlace", + "insertImage": "Imagen" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 2a54018..f698046 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -90,7 +90,8 @@ "unknownSni": "Requisições SNI desconhecidas", "unlockAccount": "Desbloquear", "verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.", - "watcherHealthy": "Monitor de certificado" + "watcherHealthy": "Monitor de certificado", + "openAdminShort": "Admin" }, "auth": { "adminLoginTitle": "Console de administração", @@ -198,7 +199,13 @@ "undoSend": "Enviando em {seconds}s", "undoSendAction": "Desfazer", "unreadCount": "{count} não lidas", - "unreadFilter": "Não lidas" + "unreadFilter": "Não lidas", + "ccLabel": "Cc", + "bccLabel": "Cco", + "hideImages": "Ocultar imagens", + "alwaysFromSender": "Sempre deste remetente", + "forgetSender": "Deixar de confiar", + "moveTo": "Mover para" }, "settings": { "actionDiscard": "Descartar", @@ -324,6 +331,11 @@ "toggleMenu": "Alternar menu", "totalEvaluated": "Mensagens avaliadas", "twoFactorSection": "Verificação em duas etapas", - "watching": "Monitorando" + "watching": "Monitorando", + "bold": "Negrito", + "italic": "Itálico", + "underline": "Sublinhado", + "insertLink": "Link", + "insertImage": "Imagem" } } diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index c6518f8..3f70695 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Button } from "../../../../components/ui/Button"; +import { signatureToHtml } from "../../../../lib/signature"; /** Splits a comma or semicolon separated field, dropping empty entries. */ function splitAddresses(value: string): string[] { @@ -69,8 +70,12 @@ export default function ComposePage({ useEffect(() => { const savedSig = localStorage.getItem("lm_user_signature"); - if (editorRef.current && savedSig) { - const initial = `

--
${savedSig}`; + // signatureToHtml, not the raw value: the signature may be the plain text + // of the old field, whose newlines are whitespace once assigned as HTML - + // which is why every line break in a signature used to vanish here. + const signatureHtml = signatureToHtml(savedSig ?? ""); + if (editorRef.current && signatureHtml) { + const initial = `

--
${signatureHtml}`; editorRef.current.innerHTML = initial; setBody(initial); } @@ -322,7 +327,7 @@ export default function ComposePage({ <>
- {t("admin.openAdmin")} + {t("admin.openAdminShort")}
)} @@ -184,7 +186,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN {initialsFor(account?.email)} - + {account?.email ?? t("common.loading")} {/* Reports what is actually configured. The old fixed "2FA @@ -242,7 +247,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* Desktop rail */} -