diff --git a/internal/db/crud_mailbox_folders.go b/internal/db/crud_mailbox_folders.go index 0fff404..e41ce23 100644 --- a/internal/db/crud_mailbox_folders.go +++ b/internal/db/crud_mailbox_folders.go @@ -1,18 +1,450 @@ package db -// CreateMailboxFolder records a custom folder's existence even before it holds any -// messages — idempotent (a folder a filter rule already delivered into can be -// explicitly created too, without erroring on the duplicate). +import ( + "database/sql" + "errors" + "sort" +) + +// StandardMailboxFolders are the folders every mailbox always has, regardless of +// whether they currently hold any mail — the single source of truth shared by the +// webmail UI (which always shows them in the sidebar) and the IMAP LIST handler +// (which must report them too, or a desktop client never learns Trash/Junk exist +// until a message happens to land in one). These 5 are always top-level and never +// renamed or reordered relative to each other — only custom folders nested under +// INBOX (or, after being deleted, under Trash — see webmailDeleteFolder) form a real +// tree; see the parent_id/parent_root doc comment on esrv_mailbox_folders. +// +// "Junk" (not "Spam") specifically because several desktop IMAP clients look for a +// folder literally named "Junk" to auto-recognize it, even though the SPECIAL-USE +// \Junk attribute (see specialUseAttrs, internal/imapserver) is supposed to make the +// exact name irrelevant — in practice not every client honors SPECIAL-USE reliably. +// See migrateSpamRenamedToJunk (schema.go) for existing mailboxes that already had a +// "Spam" folder before this rename. +var StandardMailboxFolders = []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"} + +func isStandardFolderName(name string) bool { + for _, f := range StandardMailboxFolders { + if f == name { + return true + } + } + return false +} + +// AllFoldersForMailbox is mailboxID's full folder list: the standard folders above, +// plus every folder that either holds at least one message (DistinctFoldersForMailbox) +// or was explicitly created and is still empty (ListMailboxFolders) — a folder can +// exist via either path, sometimes both. This is a flat list (IMAP LIST, the "Move +// to…" dropdown, and any other non-tree consumer) — for the sidebar's actual tree +// structure, see FolderTree. +func (d *DB) AllFoldersForMailbox(mailboxID int64) ([]string, error) { + fromMessages, err := d.DistinctFoldersForMailbox(mailboxID) + if err != nil { + return nil, err + } + explicit, err := d.ListMailboxFolders(mailboxID) + if err != nil { + return nil, err + } + seen := make(map[string]bool, len(StandardMailboxFolders)+len(fromMessages)+len(explicit)) + out := make([]string, 0, len(StandardMailboxFolders)+len(fromMessages)+len(explicit)) + add := func(f string) { + if !seen[f] { + seen[f] = true + out = append(out, f) + } + } + for _, f := range StandardMailboxFolders { + add(f) + } + for _, f := range fromMessages { + add(f) + } + for _, f := range explicit { + add(f) + } + return out, nil +} + +// unpositionedFolder is the position value a folder row gets when it exists for some +// other reason (CreateMailboxFolder, rename, becoming a parent) but has never +// actually been dragged — distinct from 0, which is a legitimate "dragged to the very +// top" position, so FolderPositions can tell "never ordered" apart from "explicitly +// ordered first". +const unpositionedFolder = -1 + +// FolderPositions returns mailboxID's saved sibling order as {folder name: position} +// — for folders that have ever been dragged to a specific spot among their siblings +// (see SetFolderOrder). A folder absent from the map has never been reordered. +// Positions are only ever compared between actual siblings (see FolderTree); the raw +// integer means nothing across different parents. +func (d *DB) FolderPositions(mailboxID int64) (map[string]int, error) { + rows, err := d.Query(`SELECT name, position FROM esrv_mailbox_folders WHERE mailbox_id = ? AND position >= 0`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var name string + var pos int + if err := rows.Scan(&name, &pos); err != nil { + return nil, err + } + out[name] = pos + } + return out, rows.Err() +} + +// SetFolderOrder persists the drag-and-drop order of one set of siblings in one shot +// — order is that parent's full children list top to bottom (never the whole tree; +// positions only mean something relative to siblings, see FolderPositions). +func (d *DB) SetFolderOrder(mailboxID int64, order []string) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for i, name := range order { + if _, err := tx.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, position) VALUES (?, ?, ?) + ON CONFLICT(mailbox_id, name) DO UPDATE SET position = excluded.position`, mailboxID, name, i); err != nil { + return err + } + } + return tx.Commit() +} + +// folderRow is one esrv_mailbox_folders row, as needed to resolve/rebuild the tree. +type folderRow struct { + id int64 + name string + parentID sql.NullInt64 + parentRoot string + position int +} + +func (d *DB) allFolderRows(mailboxID int64) ([]folderRow, error) { + rows, err := d.Query(`SELECT id, name, parent_id, parent_root, position FROM esrv_mailbox_folders WHERE mailbox_id = ?`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []folderRow + for rows.Next() { + var r folderRow + if err := rows.Scan(&r.id, &r.name, &r.parentID, &r.parentRoot, &r.position); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// FolderParentMap returns, for every custom folder that has a row, its immediate +// parent's current display name (one of the 5 standard names, or another custom +// folder's current name) — a row with neither parent_id nor parent_root set (legacy +// data, or a dangling parent_id somehow) defaults to "INBOX", never crashes. +func (d *DB) FolderParentMap(mailboxID int64) (map[string]string, error) { + all, err := d.allFolderRows(mailboxID) + if err != nil { + return nil, err + } + byID := make(map[int64]folderRow, len(all)) + for _, r := range all { + byID[r.id] = r + } + out := make(map[string]string, len(all)) + for _, r := range all { + switch { + case r.parentRoot != "": + out[r.name] = r.parentRoot + case r.parentID.Valid: + if p, ok := byID[r.parentID.Int64]; ok { + out[r.name] = p.name + } else { + out[r.name] = "INBOX" + } + default: + out[r.name] = "INBOX" + } + } + return out, nil +} + +// FolderRoot resolves which of the 5 standard folders name ultimately lives under — +// name itself if it's already standard, otherwise walking up the parent chain. Used +// to decide "is this folder (still) under INBOX" (renameable, can hold new +// subfolders) vs "under Trash" (a deleted folder's new home — see +// webmailDeleteFolder). A cycle or dangling reference (shouldn't happen) falls back +// to "INBOX" rather than looping forever. +func (d *DB) FolderRoot(mailboxID int64, name string) (string, error) { + if isStandardFolderName(name) { + return name, nil + } + parents, err := d.FolderParentMap(mailboxID) + if err != nil { + return "", err + } + seen := map[string]bool{} + cur := name + for { + if isStandardFolderName(cur) { + return cur, nil + } + if seen[cur] { + return "INBOX", nil + } + seen[cur] = true + next, ok := parents[cur] + if !ok { + return "INBOX", nil + } + cur = next + } +} + +// FolderSubtreeNames returns startName plus every descendant folder (any depth) — +// backs "Empty Trash" (Trash's whole subtree, since deleting a folder re-parents it +// there) and "Clean up Junk" (Junk never has children, so this is just ["Junk"]) when +// startName is one of the 5 standard folders, and permanently deleting or restoring +// one specific custom folder (plus whatever's nested under it) when it isn't. +func (d *DB) FolderSubtreeNames(mailboxID int64, startName string) ([]string, error) { + all, err := d.allFolderRows(mailboxID) + if err != nil { + return nil, err + } + inSubtree := map[int64]bool{} + for _, r := range all { + if r.name == startName { + inSubtree[r.id] = true + break + } + } + out := []string{startName} + // Direct children referencing startName as their standard-root parent — only + // matches when startName is itself one of the 5 standard folders. + for _, r := range all { + if r.parentRoot == startName { + out = append(out, r.name) + inSubtree[r.id] = true + } + } + // Everything deeper nests via parent_id chains — repeat until a pass finds + // nothing new, which correctly handles any depth without a recursive query. + for { + added := false + for _, r := range all { + if inSubtree[r.id] { + continue + } + if r.parentID.Valid && inSubtree[r.parentID.Int64] { + out = append(out, r.name) + inSubtree[r.id] = true + added = true + } + } + if !added { + break + } + } + return out, nil +} + +// DeleteFolderRows removes esrv_mailbox_folders records for the given names — used +// after permanently deleting a folder's messages (Empty Trash, Clean up Junk, or +// permanently deleting one Trash-nested folder), so a permanently-deleted folder +// doesn't linger as an empty shell in the sidebar the way a merely-emptied one does. +func (d *DB) DeleteFolderRows(mailboxID int64, names []string) error { + if len(names) == 0 { + return nil + } + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, name := range names { + if _, err := tx.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name); err != nil { + return err + } + } + return tx.Commit() +} + +// FolderNode is one node of the sidebar's actual folder tree — see FolderTree. +type FolderNode struct { + Name string + Children []*FolderNode + Renameable bool // a custom folder still under INBOX (not INBOX itself, not under Trash) + CanAddKid bool // INBOX itself, or a custom folder still under INBOX + UnderTrash bool // a custom folder that's been deleted into Trash — can be permanently deleted or restored +} + +// FolderTree builds the mailbox's sidebar tree: the 5 standard folders as fixed-order +// roots (never reordered relative to each other — see StandardMailboxFolders), each +// with its custom-folder descendants nested underneath, siblings ordered by any saved +// drag position (FolderPositions), unpositioned ones keeping insertion order. +func (d *DB) FolderTree(mailboxID int64) ([]*FolderNode, error) { + flat, err := d.AllFoldersForMailbox(mailboxID) + if err != nil { + return nil, err + } + parents, err := d.FolderParentMap(mailboxID) + if err != nil { + return nil, err + } + positions, err := d.FolderPositions(mailboxID) + if err != nil { + return nil, err + } + childrenOf := map[string][]string{} + for _, f := range flat { + if isStandardFolderName(f) { + continue + } + p := parents[f] + childrenOf[p] = append(childrenOf[p], f) + } + for parent := range childrenOf { + kids := childrenOf[parent] + sort.SliceStable(kids, func(i, j int) bool { + pi, oki := positions[kids[i]] + pj, okj := positions[kids[j]] + if oki && okj { + return pi < pj + } + return oki && !okj + }) + childrenOf[parent] = kids + } + var build func(name string) *FolderNode + build = func(name string) *FolderNode { + root, _ := d.FolderRoot(mailboxID, name) + n := &FolderNode{ + Name: name, + Renameable: !isStandardFolderName(name) && root == "INBOX", + CanAddKid: root == "INBOX", + UnderTrash: !isStandardFolderName(name) && root == "Trash", + } + for _, childName := range childrenOf[name] { + n.Children = append(n.Children, build(childName)) + } + return n + } + roots := make([]*FolderNode, 0, len(StandardMailboxFolders)) + for _, r := range StandardMailboxFolders { + roots = append(roots, build(r)) + } + return roots, nil +} + +// CreateMailboxFolder ensures a folder row exists, purely to hold position/rename +// metadata — idempotent (INSERT OR IGNORE), so calling it on an already-existing +// folder is a safe no-op that never clobbers that folder's real parent. Only used +// when the caller doesn't care about (or already knows) the parent — see +// CreateMailboxFolderUnder for actually creating a new folder under a chosen parent. func (d *DB) CreateMailboxFolder(mailboxID int64, name string) error { - _, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name) VALUES (?, ?)`, mailboxID, name) + _, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name, position) VALUES (?, ?, ?)`, mailboxID, name, unpositionedFolder) return err } -// DeleteMailboxFolder removes a custom folder's record. Callers are responsible for -// relocating any messages still in it first (see MoveAllMessagesInFolder) — this -// alone doesn't touch esrv_mailbox_messages. -func (d *DB) DeleteMailboxFolder(mailboxID int64, name string) error { - _, err := d.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name) +// CreateMailboxFolderUnder creates a brand-new custom folder as a child of parent — +// parent must already be valid (a standard name, or an existing custom folder with +// its own row) by the time this is called; see webmailAddFolder for the validation +// this relies on. +func (d *DB) CreateMailboxFolderUnder(mailboxID int64, name, parent string) error { + if isStandardFolderName(parent) { + _, err := d.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, position) VALUES (?, ?, NULL, ?, ?)`, + mailboxID, name, parent, unpositionedFolder) + return err + } + var parentID int64 + if err := d.QueryRow(`SELECT id FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, parent).Scan(&parentID); err != nil { + return err + } + _, err := d.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, position) VALUES (?, ?, ?, '', ?)`, + mailboxID, name, parentID, unpositionedFolder) + return err +} + +// MoveFolderToTrash re-parents a folder (and, since its descendants reference it by +// id rather than a materialized path, its whole subtree along with it) under Trash — +// this IS "delete a folder": see webmailDeleteFolder for why messages are never +// separately relocated. Captures the folder's current parent into +// restore_parent_id/restore_parent_root first, so RestoreFolder can put it back where +// it came from later. +func (d *DB) MoveFolderToTrash(mailboxID int64, name string) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var id int64 + var parentID sql.NullInt64 + var parentRoot string + err = tx.QueryRow(`SELECT id, parent_id, parent_root FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name). + Scan(&id, &parentID, &parentRoot) + switch { + case errors.Is(err, sql.ErrNoRows): + // No row yet (a message-derived-only folder, never explicitly parented) — its + // restore target defaults to INBOX, the same default FolderParentMap already + // uses for a row-less folder. + if _, err := tx.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, restore_parent_id, restore_parent_root, position) VALUES (?, ?, NULL, 'Trash', NULL, 'INBOX', ?)`, + mailboxID, name, unpositionedFolder); err != nil { + return err + } + case err != nil: + return err + default: + restoreRoot := parentRoot + if !parentID.Valid && parentRoot == "" { + restoreRoot = "INBOX" + } + if _, err := tx.Exec(`UPDATE esrv_mailbox_folders SET restore_parent_id = ?, restore_parent_root = ?, parent_id = NULL, parent_root = 'Trash' WHERE id = ?`, + parentID, restoreRoot, id); err != nil { + return err + } + } + return tx.Commit() +} + +// RestoreFolder re-parents a folder back to where it was the moment it was deleted +// (see MoveFolderToTrash's restore_parent_id/restore_parent_root capture), falling +// back to INBOX if that's no longer meaningful — its former parent has itself since +// been deleted into Trash too, so restoring underneath it would leave this folder +// looking un-restored (still inside Trash's subtree). +func (d *DB) RestoreFolder(mailboxID int64, name string) error { + var restoreParentID sql.NullInt64 + var restoreParentRoot string + if err := d.QueryRow(`SELECT restore_parent_id, restore_parent_root FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name). + Scan(&restoreParentID, &restoreParentRoot); err != nil { + return err + } + + parentID := restoreParentID + parentRoot := restoreParentRoot + if parentID.Valid { + valid := false + var parentName string + if err := d.QueryRow(`SELECT name FROM esrv_mailbox_folders WHERE id = ?`, parentID.Int64).Scan(&parentName); err == nil { + if root, err := d.FolderRoot(mailboxID, parentName); err == nil && root != "Trash" { + valid = true + } + } + if valid { + parentRoot = "" + } else { + parentID = sql.NullInt64{} + parentRoot = "INBOX" + } + } else if parentRoot == "" { + parentRoot = "INBOX" + } + + _, err := d.Exec(`UPDATE esrv_mailbox_folders SET parent_id = ?, parent_root = ?, restore_parent_id = NULL, restore_parent_root = '' WHERE mailbox_id = ? AND name = ?`, + parentID, parentRoot, mailboxID, name) return err } @@ -36,10 +468,20 @@ func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) { return out, rows.Err() } -// MoveAllMessagesInFolder reassigns every message in one folder to another — used -// when deleting a custom folder, so its messages land in INBOX instead of becoming -// orphaned in a folder nothing lists anymore. -func (d *DB) MoveAllMessagesInFolder(mailboxID int64, from, to string) error { - _, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, to, mailboxID, from) - return err +// RenameMailboxFolder changes a folder's display name only — a single-row UPDATE is +// enough (no cascade needed): children reference their parent by row id, not by name, +// so they stay correctly nested with zero further writes. +func (d *DB) RenameMailboxFolder(mailboxID int64, oldName, newName string) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`UPDATE esrv_mailbox_folders SET name = ? WHERE mailbox_id = ? AND name = ?`, newName, mailboxID, oldName); err != nil { + return err + } + if _, err := tx.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, newName, mailboxID, oldName); err != nil { + return err + } + return tx.Commit() } diff --git a/internal/db/crud_mailbox_folders_test.go b/internal/db/crud_mailbox_folders_test.go new file mode 100644 index 0000000..7463a60 --- /dev/null +++ b/internal/db/crud_mailbox_folders_test.go @@ -0,0 +1,297 @@ +package db + +import "testing" + +// TestCreateMailboxFolderDoesNotDisturbOrder is a regression test for a real bug +// caught via live testing: CreateMailboxFolder's INSERT OR IGNORE used to leave a +// fresh row's position at the column's SQL default (0), which is indistinguishable +// from "explicitly dragged to the very top" — so creating a brand-new custom folder +// made it jump above INBOX in the sidebar the instant it was created, before the user +// ever touched drag-and-drop. Fixed by writing an explicit sentinel (unpositionedFolder, +// -1) for a row that exists but was never dragged. +func TestCreateMailboxFolderDoesNotDisturbOrder(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolder(mailboxID, "Receipts"); err != nil { + t.Fatal(err) + } + folders, err := d.AllFoldersForMailbox(mailboxID) + if err != nil { + t.Fatal(err) + } + if len(folders) == 0 || folders[0] != "INBOX" { + t.Fatalf("expected INBOX first, got %v", folders) + } + if folders[len(folders)-1] != "Receipts" { + t.Fatalf("expected Receipts last (never dragged), got %v", folders) + } +} + +// treeNames flattens a []*FolderNode into a depth-first name list for easy assertion. +func treeNames(nodes []*FolderNode) []string { + var out []string + var walk func(n *FolderNode) + walk = func(n *FolderNode) { + out = append(out, n.Name) + for _, c := range n.Children { + walk(c) + } + } + for _, n := range nodes { + walk(n) + } + return out +} + +// TestAllFoldersForMailboxRootOrderIsFixed confirms the 5 standard folders are always +// returned in their fixed StandardMailboxFolders order — root folders are static and +// were deliberately made non-reorderable, so even a stray saved position for one +// (however it got there) must never change their relative order. +func TestAllFoldersForMailboxRootOrderIsFixed(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.SetFolderOrder(mailboxID, []string{"Trash", "Junk", "INBOX", "Drafts", "Sent"}); err != nil { + t.Fatal(err) + } + folders, err := d.AllFoldersForMailbox(mailboxID) + if err != nil { + t.Fatal(err) + } + want := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"} + if len(folders) != len(want) { + t.Fatalf("folders = %v, want %v", folders, want) + } + for i, name := range want { + if folders[i] != name { + t.Fatalf("folders = %v, want %v", folders, want) + } + } +} + +// TestFolderTreeNestsUnderParentAndOrdersSiblings confirms CreateMailboxFolderUnder +// actually nests a folder under its chosen parent in FolderTree, and that a saved +// sibling order (SetFolderOrder, scoped to just that parent's children) is respected +// — a folder created afterwards (never dragged) appends after the ordered siblings. +func TestFolderTreeNestsUnderParentAndOrdersSiblings(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientB", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.SetFolderOrder(mailboxID, []string{"ClientB", "ClientA"}); err != nil { + t.Fatal(err) + } + + tree, err := d.FolderTree(mailboxID) + if err != nil { + t.Fatal(err) + } + got := treeNames(tree) + want := []string{"INBOX", "Projects", "ClientB", "ClientA", "Junk", "Sent", "Drafts", "Trash"} + if len(got) != len(want) { + t.Fatalf("tree = %v, want %v", got, want) + } + for i, name := range want { + if got[i] != name { + t.Fatalf("tree = %v, want %v", got, want) + } + } + + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientC", "Projects"); err != nil { + t.Fatal(err) + } + tree, err = d.FolderTree(mailboxID) + if err != nil { + t.Fatal(err) + } + got = treeNames(tree) + if got[len(got)-5] != "ClientC" { // last of Projects' 3 children, before Junk/Sent/Drafts/Trash + t.Fatalf("expected ClientC to append after the ordered siblings, got %v", got) + } +} + +// TestRenameMailboxFolderKeepsChildrenNested confirms renaming a folder that has +// children leaves them correctly nested (they reference their parent by row id, not +// by name, so a plain single-row UPDATE never orphans them). +func TestRenameMailboxFolderKeepsChildrenNested(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.RenameMailboxFolder(mailboxID, "Projects", "Work"); err != nil { + t.Fatal(err) + } + + root, err := d.FolderRoot(mailboxID, "ClientA") + if err != nil || root != "INBOX" { + t.Fatalf("expected ClientA to still resolve under INBOX after its parent was renamed, root=%q err=%v", root, err) + } + tree, err := d.FolderTree(mailboxID) + if err != nil { + t.Fatal(err) + } + got := treeNames(tree) + want := []string{"INBOX", "Work", "ClientA", "Junk", "Sent", "Drafts", "Trash"} + if len(got) != len(want) { + t.Fatalf("tree = %v, want %v", got, want) + } + for i, name := range want { + if got[i] != name { + t.Fatalf("tree = %v, want %v", got, want) + } + } +} + +// TestMoveFolderToTrashKeepsSubtreeIntact confirms deleting a folder that has its own +// children re-parents the whole subtree under Trash in one move — the children, +// referencing their parent by id, come along automatically with zero further writes. +func TestMoveFolderToTrashKeepsSubtreeIntact(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.MoveFolderToTrash(mailboxID, "Projects"); err != nil { + t.Fatal(err) + } + + for _, name := range []string{"Projects", "ClientA"} { + root, err := d.FolderRoot(mailboxID, name) + if err != nil || root != "Trash" { + t.Fatalf("expected %s to resolve under Trash, got root=%q (err=%v)", name, root, err) + } + } + names, err := d.FolderSubtreeNames(mailboxID, "Trash") + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"Trash": true, "Projects": true, "ClientA": true} + if len(names) != len(want) { + t.Fatalf("FolderSubtreeNames(Trash) = %v, want exactly %v", names, want) + } + for _, n := range names { + if !want[n] { + t.Fatalf("FolderSubtreeNames(Trash) = %v, want exactly %v", names, want) + } + } +} + +// TestRestoreFolderReturnsToOriginalParent confirms deleting a nested folder and then +// restoring it puts it back exactly where it was (not just dumped at INBOX's top +// level) — its sibling, untouched throughout, proves the parent (Projects) itself +// never moved. +func TestRestoreFolderReturnsToOriginalParent(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.MoveFolderToTrash(mailboxID, "ClientA"); err != nil { + t.Fatal(err) + } + if root, err := d.FolderRoot(mailboxID, "ClientA"); err != nil || root != "Trash" { + t.Fatalf("expected ClientA under Trash after delete, root=%q err=%v", root, err) + } + + if err := d.RestoreFolder(mailboxID, "ClientA"); err != nil { + t.Fatal(err) + } + tree, err := d.FolderTree(mailboxID) + if err != nil { + t.Fatal(err) + } + got := treeNames(tree) + want := []string{"INBOX", "Projects", "ClientA", "Junk", "Sent", "Drafts", "Trash"} + if len(got) != len(want) { + t.Fatalf("tree after restore = %v, want %v", got, want) + } + for i, name := range want { + if got[i] != name { + t.Fatalf("tree after restore = %v, want %v", got, want) + } + } +} + +// TestRestoreFolderFallsBackToInboxWhenOriginalParentStillTrashed confirms restoring +// a folder whose former parent is itself still in Trash doesn't leave it looking +// un-restored (nested inside another trashed folder) — it falls back to INBOX +// instead. +func TestRestoreFolderFallsBackToInboxWhenOriginalParentStillTrashed(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.MoveFolderToTrash(mailboxID, "ClientA"); err != nil { + t.Fatal(err) + } + // Now trash Projects too — ClientA's restore target (Projects) is itself trashed. + if err := d.MoveFolderToTrash(mailboxID, "Projects"); err != nil { + t.Fatal(err) + } + if err := d.RestoreFolder(mailboxID, "ClientA"); err != nil { + t.Fatal(err) + } + root, err := d.FolderRoot(mailboxID, "ClientA") + if err != nil || root != "INBOX" { + t.Fatalf("expected ClientA to fall back under INBOX, got root=%q (err=%v)", root, err) + } +} + +// TestDeleteFolderRowsRemovesRecords confirms DeleteFolderRows actually removes the +// esrv_mailbox_folders rows (not just messages) — used by permanent deletes so a +// permanently-removed folder doesn't linger as an empty shell in FolderTree. +func TestDeleteFolderRowsRemovesRecords(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil { + t.Fatal(err) + } + if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil { + t.Fatal(err) + } + if err := d.DeleteFolderRows(mailboxID, []string{"Projects", "ClientA"}); err != nil { + t.Fatal(err) + } + tree, err := d.FolderTree(mailboxID) + if err != nil { + t.Fatal(err) + } + got := treeNames(tree) + want := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"} + if len(got) != len(want) { + t.Fatalf("tree after DeleteFolderRows = %v, want %v (Projects/ClientA gone)", got, want) + } + for i, name := range want { + if got[i] != name { + t.Fatalf("tree after DeleteFolderRows = %v, want %v", got, want) + } + } +} diff --git a/internal/db/crud_mailbox_messages.go b/internal/db/crud_mailbox_messages.go index 5a3ef63..b6e4e74 100644 --- a/internal/db/crud_mailbox_messages.go +++ b/internal/db/crud_mailbox_messages.go @@ -59,6 +59,33 @@ func (d *DB) MoveMessage(mailboxID, uid int64, newFolder string) error { return err } +// MoveMessageToTrash moves a message to Trash, first capturing its current folder +// into restore_folder so RestoreMessage can put it back later (mirrors +// db.MoveFolderToTrash's identical capture for folders). +func (d *DB) MoveMessageToTrash(mailboxID, uid int64) error { + var folder string + if err := d.QueryRow(`SELECT folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID).Scan(&folder); err != nil { + return err + } + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET restore_folder = ?, folder = 'Trash' WHERE id = ? AND mailbox_id = ?`, folder, uid, mailboxID) + return err +} + +// RestoreMessage moves a message back to the folder it was in immediately before +// being moved to Trash (see MoveMessageToTrash), or INBOX if that's unknown (e.g. a +// message trashed before this feature existed, or moved to Trash some other way). +func (d *DB) RestoreMessage(mailboxID, uid int64) error { + var restoreFolder string + if err := d.QueryRow(`SELECT restore_folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID).Scan(&restoreFolder); err != nil { + return err + } + if restoreFolder == "" { + restoreFolder = "INBOX" + } + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ?, restore_folder = '' WHERE id = ? AND mailbox_id = ?`, restoreFolder, uid, mailboxID) + return err +} + // ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by // mailbox removal to delete each one's on-disk ciphertext via mailstore before the // mailbox row itself is removed. @@ -155,12 +182,15 @@ func sortColumnAndDir(sortBy, sortDir string) string { return col + " " + dir + ", id " + dir } -func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) { +func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly, starredOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) { query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?` args := []any{mailboxID, folder} if unreadOnly { query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'` } + if starredOnly { + query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'` + } query += ` ORDER BY ` + sortColumnAndDir(sortBy, sortDir) + ` LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := d.Query(query, args...) @@ -171,17 +201,59 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly } // CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls. -func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly bool) (int, error) { +func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly, starredOnly bool) (int, error) { query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?` args := []any{mailboxID, folder} if unreadOnly { query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'` } + if starredOnly { + query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'` + } var n int err := d.QueryRow(query, args...).Scan(&n) return n, err } +// MarkAllReadInFolder adds \Seen to every currently-unread message in one folder — +// the folder-context-menu "Mark all as read" action's bulk equivalent of opening each +// message individually. Messages that already have other flags (e.g. \Flagged) keep +// them; only \Seen is added. +func (d *DB) MarkAllReadInFolder(mailboxID int64, folder string) error { + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET flags = TRIM(flags || ' \Seen') + WHERE mailbox_id = ? AND folder = ? AND flags NOT LIKE '%\Seen%' ESCAPE '\'`, mailboxID, folder) + return err +} + +// ToggleMessageStarred flips \Flagged (IMAP's standard "starred/important" flag) on +// one message — reusing the existing flags column instead of a new schema column +// keeps this consistent with a desktop IMAP client's own star/flag button acting on +// the same message. +func (d *DB) ToggleMessageStarred(mailboxID, uid int64) error { + msg, err := d.GetMessageByUID(mailboxID, uid) + if err != nil || msg == nil { + return err + } + var newFlags string + if isStarred(msg.Flags) { + newFlags = strings.TrimSpace(strings.ReplaceAll(msg.Flags, `\Flagged`, "")) + } else { + newFlags = strings.TrimSpace(msg.Flags + ` \Flagged`) + } + return d.SetMessageFlags(mailboxID, uid, newFlags) +} + +// isStarred reports whether flags (the same space-separated IMAP flags string +// isUnread checks in internal/webui) includes \Flagged. +func isStarred(flags string) bool { + for _, f := range strings.Fields(flags) { + if f == `\Flagged` { + return true + } + } + return false +} + // escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters // (%, _, and the escape character itself) so a search for e.g. "50% off" or a // filename with an underscore doesn't get interpreted as a wildcard. diff --git a/internal/db/crud_mailbox_signatures.go b/internal/db/crud_mailbox_signatures.go new file mode 100644 index 0000000..7cda84d --- /dev/null +++ b/internal/db/crud_mailbox_signatures.go @@ -0,0 +1,107 @@ +package db + +import ( + "database/sql" + "errors" +) + +const signatureColumns = `id, mailbox_id, name, content_html, is_default_new, is_default_reply, created_at` + +func scanSignature(scan func(dest ...any) error) (MailboxSignature, error) { + var s MailboxSignature + var createdAt string + err := scan(&s.ID, &s.MailboxID, &s.Name, &s.ContentHTML, &s.IsDefaultNew, &s.IsDefaultReply, &createdAt) + if err != nil { + return s, err + } + s.CreatedAt, _ = parseTime(createdAt) + return s, nil +} + +// ListSignatures returns a mailbox's saved signatures, most recently created first. +func (d *DB) ListSignatures(mailboxID int64) ([]MailboxSignature, error) { + rows, err := d.Query(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE mailbox_id = ? ORDER BY id DESC`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MailboxSignature + for rows.Next() { + s, err := scanSignature(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// GetSignatureByID scopes the lookup to mailboxID so one mailbox can never read or +// (via UpdateSignature/DeleteSignature, which reuse this same WHERE clause) modify +// another's signature by guessing an id. +func (d *DB) GetSignatureByID(mailboxID, id int64) (*MailboxSignature, error) { + row := d.QueryRow(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID) + s, err := scanSignature(row.Scan) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +// GetDefaultSignature returns the mailbox's default-for-new (forReply=false) or +// default-for-reply/forward (forReply=true) signature, or nil if none is set. +func (d *DB) GetDefaultSignature(mailboxID int64, forReply bool) (*MailboxSignature, error) { + col := "is_default_new" + if forReply { + col = "is_default_reply" + } + row := d.QueryRow(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE mailbox_id = ? AND `+col+` = 1 LIMIT 1`, mailboxID) + s, err := scanSignature(row.Scan) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +func (d *DB) CreateSignature(mailboxID int64, name, contentHTML string) (int64, error) { + res, err := d.Exec(`INSERT INTO esrv_mailbox_signatures (mailbox_id, name, content_html) VALUES (?, ?, ?)`, mailboxID, name, contentHTML) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (d *DB) UpdateSignature(mailboxID, id int64, name, contentHTML string) error { + _, err := d.Exec(`UPDATE esrv_mailbox_signatures SET name = ?, content_html = ? WHERE id = ? AND mailbox_id = ?`, name, contentHTML, id, mailboxID) + return err +} + +func (d *DB) DeleteSignature(mailboxID, id int64) error { + _, err := d.Exec(`DELETE FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID) + return err +} + +// SetDefaultSignature makes id the mailbox's default-for-new (forReply=false) or +// default-for-reply/forward (forReply=true) signature, clearing the flag from every +// other row first so at most one ever holds it — id=0 just clears the flag from +// everything, i.e. "no default". +func (d *DB) SetDefaultSignature(mailboxID, id int64, forReply bool) error { + col := "is_default_new" + if forReply { + col = "is_default_reply" + } + if _, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 0 WHERE mailbox_id = ?`, mailboxID); err != nil { + return err + } + if id == 0 { + return nil + } + _, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 1 WHERE id = ? AND mailbox_id = ?`, id, mailboxID) + return err +} diff --git a/internal/db/mailbox_models.go b/internal/db/mailbox_models.go index ce06c65..3d255ee 100644 --- a/internal/db/mailbox_models.go +++ b/internal/db/mailbox_models.go @@ -152,6 +152,19 @@ type MailboxMessage struct { CreatedAt time.Time } +// MailboxSignature is one of a mailbox's saved email signatures — HTML content, shown +// as an option in compose and optionally auto-inserted for a new message or a +// reply/forward (IsDefaultNew/IsDefaultReply). +type MailboxSignature struct { + ID int64 + MailboxID int64 + Name string + ContentHTML string + IsDefaultNew bool + IsDefaultReply bool + CreatedAt time.Time +} + // MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key // pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is // sign-only in this codebase, so the key never protects anything beyond what the diff --git a/internal/db/message_restore_test.go b/internal/db/message_restore_test.go new file mode 100644 index 0000000..c190d82 --- /dev/null +++ b/internal/db/message_restore_test.go @@ -0,0 +1,53 @@ +package db + +import ( + "testing" + "time" +) + +// TestMoveMessageToTrashAndRestore confirms a message trashed via MoveMessageToTrash +// remembers its prior folder and RestoreMessage puts it back there. +func TestMoveMessageToTrashAndRestore(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + uid, err := d.InsertMessage(mailboxID, "Newsletters", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", "") + if err != nil { + t.Fatal(err) + } + if err := d.MoveMessageToTrash(mailboxID, uid); err != nil { + t.Fatal(err) + } + msg, err := d.GetMessageByUID(mailboxID, uid) + if err != nil || msg.Folder != "Trash" { + t.Fatalf("expected message in Trash, got folder=%q (err=%v)", msg.Folder, err) + } + + if err := d.RestoreMessage(mailboxID, uid); err != nil { + t.Fatal(err) + } + msg, err = d.GetMessageByUID(mailboxID, uid) + if err != nil || msg.Folder != "Newsletters" { + t.Fatalf("expected message restored to Newsletters, got folder=%q (err=%v)", msg.Folder, err) + } +} + +// TestRestoreMessageFallsBackToInboxWhenUnknown confirms a message with no recorded +// restore_folder (e.g. trashed before this feature existed) restores to INBOX rather +// than erroring or restoring to an empty folder name. +func TestRestoreMessageFallsBackToInboxWhenUnknown(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + uid, err := d.InsertMessage(mailboxID, "Trash", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", "") + if err != nil { + t.Fatal(err) + } + if err := d.RestoreMessage(mailboxID, uid); err != nil { + t.Fatal(err) + } + msg, err := d.GetMessageByUID(mailboxID, uid) + if err != nil || msg.Folder != "INBOX" { + t.Fatalf("expected fallback to INBOX, got folder=%q (err=%v)", msg.Folder, err) + } +} diff --git a/internal/db/schema.go b/internal/db/schema.go index 15bb259..f3af05b 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -316,6 +316,11 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages ( -- other cached_* columns) so the folder list can show a preview snippet without -- decrypting the full message just to render the list. cached_preview TEXT NOT NULL DEFAULT '', + -- Snapshot of folder taken the moment a message is moved to Trash (see + -- db.MoveMessageToTrash), so "Restore" (db.RestoreMessage) can put it back where + -- it came from instead of just dumping it in INBOX. Empty outside of that window + -- (cleared again once restored). + restore_folder TEXT NOT NULL DEFAULT '', storage_path TEXT NOT NULL, nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -328,13 +333,47 @@ CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages( -- Explicit record of a mailbox's custom folders, so a freshly created (still empty) -- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove --- a folder exists once it holds at least one message. Standard folders (INBOX, Spam, --- Sent, Drafts, Trash) are never stored here; they're always shown by the webui --- regardless of this table. +-- a folder exists once it holds at least one message. Standard folders (INBOX, Junk, +-- Sent, Drafts, Trash) don't need a row to be listed — they're always shown by the +-- webui regardless — but DO get one the first time they're renamed, dragged to a new +-- position, or made a parent, purely to hold that metadata (see CreateMailboxFolder, +-- SetFolderOrder). +-- +-- Custom folders form a real tree, always rooted at one of the 5 standard folders +-- (webui only ever offers "New folder" under INBOX, and "delete a folder" re-parents +-- it under Trash — see webmailDeleteFolder). Exactly one of parent_id/parent_root is +-- ever set per row: parent_id (an id, not a name) when the parent is another custom +-- folder — immune to that parent later being renamed, unlike a name-based reference — +-- parent_root (one of the 5 fixed, never-renamed standard names) when the parent is a +-- standard folder that may not have its own row. A row with BOTH unset (parent_id +-- NULL, parent_root '') is legacy data predating this column and is treated as +-- "under INBOX" (see db.FolderParentMap) — every custom folder created before this +-- feature was flat/top-level anyway, so that default is exactly correct, no backfill +-- migration needed. +-- +-- esrv_mailbox_messages.folder itself is untouched by any of this — messages are +-- still tagged with a flat folder name exactly as before; the parent/child +-- relationship here is purely an organizational layer for the sidebar. CREATE TABLE IF NOT EXISTS esrv_mailbox_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), name TEXT NOT NULL, + parent_id INTEGER REFERENCES esrv_mailbox_folders(id), + parent_root TEXT NOT NULL DEFAULT '', + -- Sidebar sort position among this folder's siblings (lower first), set only once + -- the user drags to reorder. The app always writes an explicit value: -1 + -- (db.unpositionedFolder) for a row that exists for some other reason (rename, a + -- folder just created, becoming a parent) but was never dragged, so it doesn't + -- look like a real "dragged to the very top" (0) — FolderPositions filters on + -- position >= 0 for exactly this reason. Column default of 0 is inert (every write + -- path is explicit); kept only as a harmless fallback. + position INTEGER NOT NULL DEFAULT 0, + -- Snapshot of parent_id/parent_root taken the moment a folder is deleted (moved + -- under Trash — see db.MoveFolderToTrash), so "Restore" (db.RestoreFolder) can put + -- it back where it came from instead of just dumping it at INBOX's top level. + -- Empty/NULL outside of that window (cleared again once restored). + restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id), + restore_parent_root TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, name) ); @@ -391,6 +430,25 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- A mailbox's saved email signatures — HTML content (compose is HTML/Quill-based +-- already; a plain-text signature is just HTML with no formatting, so one content +-- column covers both instead of storing two parallel copies). A mailbox may hold +-- several, e.g. one formal and one casual; is_default_new/is_default_reply pick which +-- one (if any) compose pre-fills for a brand-new message vs a reply/forward — at most +-- one row per mailbox should have each flag set, enforced in the db package (Go), not +-- a SQL constraint, since "make this one the default" is naturally an +-- update-this-then-clear-the-others operation either way. +CREATE TABLE IF NOT EXISTS esrv_mailbox_signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), + name TEXT NOT NULL, + content_html TEXT NOT NULL DEFAULT '', + is_default_new INTEGER NOT NULL DEFAULT 0, + is_default_reply INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_mailbox_signatures_mailbox ON esrv_mailbox_signatures(mailbox_id); + -- Other people's PGP public keys a mailbox owner has collected, added by hand — -- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a -- recipient in compose. @@ -441,6 +499,12 @@ func migrateAddedColumns(db *sql.DB) { `ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailboxes ADD COLUMN group_messages INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_preview TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE esrv_mailbox_folders ADD COLUMN position INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`, + `ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_root TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`, + `ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_root TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE esrv_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`, } // The three old columns above were NOT NULL with no default, so simply adding // key_pem left them behind still blocking every new insert (which only ever sets @@ -458,6 +522,23 @@ func migrateAddedColumns(db *sql.DB) { // defaults to 0 for every pre-existing row above, which would otherwise let that // account skip its username change entirely once it re-hits /first-login next. db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername) + migrateSpamRenamedToJunk(db) +} + +// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes +// that already had messages/records under the old name — "Junk" is what most desktop +// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always +// safe to re-run: once nothing's left named "Spam" every UPDATE here matches zero +// rows. +// ponytail: doesn't handle the (very unlikely) case where a mailbox already has an +// unrelated custom folder literally named "Junk" before this rename — that one row's +// UPDATE would fail on the UNIQUE(mailbox_id, name) constraint and get silently +// skipped, same as every other best-effort statement in this function. Rename that +// mailbox's pre-existing "Junk" folder first if this ever comes up in practice. +func migrateSpamRenamedToJunk(db *sql.DB) { + db.Exec(`UPDATE esrv_mailbox_messages SET folder = 'Junk' WHERE folder = 'Spam'`) + db.Exec(`UPDATE esrv_mailbox_folders SET name = 'Junk' WHERE name = 'Spam'`) + db.Exec(`UPDATE esrv_mailbox_folders SET parent_root = 'Junk' WHERE parent_root = 'Spam'`) } // DB wraps *sql.DB with the query helpers below. diff --git a/internal/imapserver/imapserver_test.go b/internal/imapserver/imapserver_test.go index 5817dec..682e10c 100644 --- a/internal/imapserver/imapserver_test.go +++ b/internal/imapserver/imapserver_test.go @@ -174,7 +174,7 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) { if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "", "a@example.com", "normal"); err != nil { t.Fatal(err) } - if _, err := store.StoreMessage(mailboxID, "Spam", []byte("Subject: junk\r\n\r\nspam"), "", "b@example.com", "junk"); err != nil { + if _, err := store.StoreMessage(mailboxID, "Junk", []byte("Subject: junk\r\n\r\nspam"), "", "b@example.com", "junk"); err != nil { t.Fatal(err) } @@ -201,12 +201,46 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) { if err != nil { t.Fatalf("list: %v", err) } - var names []string + // LIST must report every standard folder (INBOX, Junk, Sent, Drafts, Trash) even + // though only INBOX and Junk actually hold a message here — a desktop client that + // only sees folders with existing mail never learns Trash/Drafts/Sent exist. See + // db.AllFoldersForMailbox / imapserver.Session.List. + byName := map[string]*imap.ListData{} for _, m := range mailboxes { - names = append(names, m.Mailbox) + byName[m.Mailbox] = m } - if len(names) != 2 { - t.Fatalf("expected 2 folders (INBOX, Spam), got %v", names) + wantFolders := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"} + if len(mailboxes) != len(wantFolders) { + names := make([]string, 0, len(mailboxes)) + for _, m := range mailboxes { + names = append(names, m.Mailbox) + } + t.Fatalf("expected folders %v, got %v", wantFolders, names) + } + for _, name := range wantFolders { + if _, ok := byName[name]; !ok { + t.Errorf("LIST is missing folder %q", name) + } + } + // Trash/Junk/Sent/Drafts should each carry their RFC 6154 SPECIAL-USE attribute + // so a desktop client (Thunderbird, Apple Mail, etc.) recognizes them regardless + // of the exact folder name. + wantAttrs := map[string]imap.MailboxAttr{ + "Trash": imap.MailboxAttrTrash, + "Junk": imap.MailboxAttrJunk, + "Sent": imap.MailboxAttrSent, + "Drafts": imap.MailboxAttrDrafts, + } + for name, attr := range wantAttrs { + found := false + for _, a := range byName[name].Attrs { + if a == attr { + found = true + } + } + if !found { + t.Errorf("folder %q missing SPECIAL-USE attr %q, got %v", name, attr, byName[name].Attrs) + } } inboxData, err := client.Select("INBOX", nil).Wait() @@ -217,20 +251,20 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) { t.Fatalf("INBOX NumMessages = %d, want 1", inboxData.NumMessages) } - spamData, err := client.Select("Spam", nil).Wait() + spamData, err := client.Select("Junk", nil).Wait() if err != nil { - t.Fatalf("select Spam: %v", err) + t.Fatalf("select Junk: %v", err) } if spamData.NumMessages != 1 { - t.Fatalf("Spam NumMessages = %d, want 1", spamData.NumMessages) + t.Fatalf("Junk NumMessages = %d, want 1", spamData.NumMessages) } msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect() if err != nil { - t.Fatalf("fetch in Spam: %v", err) + t.Fatalf("fetch in Junk: %v", err) } if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" { - t.Fatalf("expected the Spam-folder message (subject %q), got %+v", "junk", msgs) + t.Fatalf("expected the Junk-folder message (subject %q), got %+v", "junk", msgs) } } diff --git a/internal/imapserver/session.go b/internal/imapserver/session.go index 823bd75..3cb35a6 100644 --- a/internal/imapserver/session.go +++ b/internal/imapserver/session.go @@ -177,14 +177,37 @@ func (s *Session) Unsubscribe(mailbox string) error { return nil } -// List reports every folder that actually has mail (plus INBOX, always) — a filter -// rule's move_to_folder action is what creates a second folder; there's no IMAP -// CREATE/manual folder management. +// specialUseAttrs tags a folder with its RFC 6154 SPECIAL-USE attribute, if any — +// desktop IMAP clients (Thunderbird, Apple Mail, K-9, etc.) use this to recognize +// Trash/Junk/Sent/Drafts regardless of the exact folder name, though in practice not +// every client honors SPECIAL-USE reliably, which is why the folder itself is named +// "Junk" (see db.StandardMailboxFolders) rather than relying on this attribute alone. +func specialUseAttrs(folder string) []imap.MailboxAttr { + switch folder { + case "Trash": + return []imap.MailboxAttr{imap.MailboxAttrTrash} + case "Junk": + return []imap.MailboxAttr{imap.MailboxAttrJunk} + case "Sent": + return []imap.MailboxAttr{imap.MailboxAttrSent} + case "Drafts": + return []imap.MailboxAttr{imap.MailboxAttrDrafts} + default: + return nil + } +} + +// List reports every folder the mailbox has: the standard folders (INBOX, Junk, Sent, +// Drafts, Trash — always, even empty) plus any custom folder a filter rule's +// move_to_folder action or explicit webmail folder creation has produced — the same +// full list db.AllFoldersForMailbox gives the webmail UI, so a desktop IMAP client +// sees exactly the same folders webmail does instead of only ones that happen to +// already hold a message (DistinctFoldersForMailbox alone). func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error { if err := s.requireAuth(); err != nil { return err } - folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID) + folders, err := s.backend.DB.AllFoldersForMailbox(s.mailbox.ID) if err != nil { return err } @@ -199,7 +222,7 @@ func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string if !goimapserver.MatchList(folder, '/', ref, pattern) { continue } - if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil { + if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/', Attrs: specialUseAttrs(folder)}); err != nil { return err } } diff --git a/internal/mailstore/rules.go b/internal/mailstore/rules.go index 927f6ef..245b5f3 100644 --- a/internal/mailstore/rules.go +++ b/internal/mailstore/rules.go @@ -35,10 +35,10 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc case "move_to_folder": return FilterAction{Folder: r.ActionValue}, nil case "mark_as_spam": - // Reuses the same Spam folder score-based quarantine already delivers + // Reuses the same Junk folder score-based quarantine already delivers // into (see smtpserver/session.go) — from the mailbox owner's - // perspective it's the same "goes to Spam" outcome either way. - return FilterAction{Folder: "Spam"}, nil + // perspective it's the same "goes to Junk" outcome either way. + return FilterAction{Folder: "Junk"}, nil case "delete": return FilterAction{Drop: true}, nil case "mark_read": diff --git a/internal/mailstore/rules_test.go b/internal/mailstore/rules_test.go index 8d47d42..439c70a 100644 --- a/internal/mailstore/rules_test.go +++ b/internal/mailstore/rules_test.go @@ -79,7 +79,7 @@ func TestApplyRulesMarkAsSpam(t *testing.T) { if err != nil { t.Fatal(err) } - if action.Folder != "Spam" { + if action.Folder != "Junk" { t.Fatalf("expected mark_as_spam to route into the Spam folder, got %+v", action) } } diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 60a6020..76a4c3e 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -27,7 +27,7 @@ type Result struct { ErrorCode string ErrorMessage string ServerResponse string - // Quarantined is true for a local delivery that landed in Spam rather than INBOX. + // Quarantined is true for a local delivery that landed in Junk rather than INBOX. // Only ever set by smtpserver's local-delivery path (always false for outbound // relay results) — it's the signal Data() uses to keep this message's body in the // admin log despite content-logging otherwise being off by default, so a spam/ diff --git a/internal/smtpserver/log_privacy_test.go b/internal/smtpserver/log_privacy_test.go index 2a497f1..04275bb 100644 --- a/internal/smtpserver/log_privacy_test.go +++ b/internal/smtpserver/log_privacy_test.go @@ -68,7 +68,7 @@ func TestEmailLogBodyKeptWhenQuarantined(t *testing.T) { t.Fatalf("send: %v", err) } - spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk") if err != nil { t.Fatal(err) } diff --git a/internal/smtpserver/mailbox_rules_test.go b/internal/smtpserver/mailbox_rules_test.go index 3cbf350..ced7fff 100644 --- a/internal/smtpserver/mailbox_rules_test.go +++ b/internal/smtpserver/mailbox_rules_test.go @@ -70,7 +70,7 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) { if err := send(t); err != nil { t.Fatalf("expected delivery accepted (quarantined) with a zero reject threshold and no allow-list entry, got: %v", err) } - spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk") if err != nil || len(spamMsgs) != 1 { t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err) } @@ -170,7 +170,7 @@ func TestFilterRuleMarkReadSetsSeenFlag(t *testing.T) { func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) { backend, mailboxID := newTestBackendWithMailbox(t) - if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Spam"); err != nil { + if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Junk"); err != nil { t.Fatal(err) } addr := startTestServer(t, backend) @@ -205,7 +205,7 @@ func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) { if len(inbox) != 0 { t.Fatalf("expected nothing in INBOX, found %d", len(inbox)) } - spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk") if err != nil { t.Fatal(err) } diff --git a/internal/smtpserver/mailbox_spam_test.go b/internal/smtpserver/mailbox_spam_test.go index 341fb24..63f878d 100644 --- a/internal/smtpserver/mailbox_spam_test.go +++ b/internal/smtpserver/mailbox_spam_test.go @@ -58,7 +58,7 @@ func TestRspamdExplicitRejectActionStillHardRejects(t *testing.T) { t.Fatal("expected delivery to be hard-rejected when rspamd's action is \"reject\"") } inboxMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX") - spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Junk") if len(inboxMsgs) != 0 || len(spamMsgs) != 0 { t.Fatalf("expected nothing stored anywhere for a hard reject, got INBOX=%d Spam=%d", len(inboxMsgs), len(spamMsgs)) } @@ -78,7 +78,7 @@ func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(t *testing.T) { if err := sendTestMessage(t, addr, "hi"); err != nil { t.Fatalf("expected delivery accepted (quarantined), got: %v", err) } - spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk") if err != nil || len(spamMsgs) != 1 { t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err) } diff --git a/internal/smtpserver/session.go b/internal/smtpserver/session.go index 2b7968b..4b2d591 100644 --- a/internal/smtpserver/session.go +++ b/internal/smtpserver/session.go @@ -368,7 +368,7 @@ func (s *Session) Data(r io.Reader) error { // Privacy default: only headers (and the Subject field, logged separately below // regardless) go into the admin-visible log, never the message itself — unless this // sender/IP explicitly opted in via "Store Full Message Content" (storeMessage - // above), or the message was quarantined to Spam for at least one recipient, in + // above), or the message was quarantined to Junk for at least one recipient, in // which case an admin genuinely needs to see it to judge a spam/abuse report. When // stored, it's the *entire* raw message (not a plain-text extraction) so the log // viewer can render the real HTML body, inline images, and attachments — re-parsed @@ -447,7 +447,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID // (DNSBL hit, greylisting, etc.) worth still hard-rejecting at // SMTP time to avoid backscatter; a bare score threshold hit // (from either scorer) is quarantined instead of rejected, so a - // false positive is recoverable from the Spam folder rather than + // false positive is recoverable from the Junk folder rather than // silently bounced with no trace. if rAction == "reject" { hardReject = true @@ -463,14 +463,14 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID continue } if quarantine { - folder = "Spam" + folder = "Junk" spamGated = true } } // Filter rules organize legitimate mail the recipient already trusts arriving // in their INBOX — a quarantined message skips them entirely and always lands - // in Spam, rather than a rule accidentally routing spam back into view. + // in Junk, rather than a rule accidentally routing spam back into view. if !spamGated { action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject}) if err != nil { @@ -503,7 +503,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID } serverResponse := "Delivered to local mailbox" if spamGated { - serverResponse = "Quarantined to Spam folder" + serverResponse = "Quarantined to Junk folder" } results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated}) } diff --git a/internal/webui/mailbox_rules_test.go b/internal/webui/mailbox_rules_test.go index f65b39a..814577f 100644 --- a/internal/webui/mailbox_rules_test.go +++ b/internal/webui/mailbox_rules_test.go @@ -49,7 +49,7 @@ func TestAdminRulesAddMultiConditionAndRenders(t *testing.T) { if !strings.Contains(body, "to contains "sales"") || !strings.Contains(body, "AND") { t.Fatalf("expected the rendered condition summary to show both AND'd conditions, got: %s", body) } - if !strings.Contains(body, "Mark as Spam") { + if !strings.Contains(body, "Mark as Junk") { t.Fatal("expected the mark_as_spam action to render") } } diff --git a/internal/webui/render.go b/internal/webui/render.go index 55aa575..a31c20c 100644 --- a/internal/webui/render.go +++ b/internal/webui/render.go @@ -86,6 +86,7 @@ func (a *App) funcMap() template.FuncMap { }, "list": func(items ...string) []string { return items }, "isStandardFolder": isStandardFolder, + "folderIcon": folderIcon, // emailOverallStatus mirrors the delivered/failed selectattr computation // dashboard.html and logs.html both do in the Python templates. "emailOverallStatus": func(recipients []db.EmailRecipientLog) string { @@ -169,6 +170,7 @@ var standalonePages = []string{ "login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html", "webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html", "webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html", + "webmail_signatures.html", // A bare HTML fragment (no /base.html chrome at all), fetched via JS and // injected into logs.html's full-screen modal — not a page anyone navigates to // directly, so it doesn't need to look like a standalone document the way the @@ -186,6 +188,7 @@ var standalonePages = []string{ // something that opens a popup of its own. var pagesWithComposeWidget = []string{ "webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html", + "webmail_signatures.html", } func hasComposeWidget(page string) bool { diff --git a/internal/webui/templates/mailbox_rules.html b/internal/webui/templates/mailbox_rules.html index 6719d07..6cd0454 100644 --- a/internal/webui/templates/mailbox_rules.html +++ b/internal/webui/templates/mailbox_rules.html @@ -37,7 +37,7 @@ @@ -93,7 +93,7 @@
{{ruleSummary .}}