488 lines
17 KiB
Go
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()
|
|
}
|