Files
mailgoserver/internal/db/crud_mailbox_folders.go
T

488 lines
17 KiB
Go

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()
}