package db 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, position) VALUES (?, ?, ?)`, mailboxID, name, unpositionedFolder) return err } // 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 } // ListMailboxFolders returns a mailbox's explicitly-created custom folders — combine // with DistinctFoldersForMailbox (message-derived) for the full folder list, since a // folder can exist via either path (or both). func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) { rows, err := d.Query(`SELECT name FROM esrv_mailbox_folders WHERE mailbox_id = ? ORDER BY name`, mailboxID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } out = append(out, name) } return out, rows.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() }