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({
<>