updated layout for webmail
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user