updated layout for webmail

This commit is contained in:
2026-08-15 16:31:27 +01:00
parent f283c90f11
commit 6f0c305367
35 changed files with 2660 additions and 219 deletions
+457 -15
View File
@@ -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()
}
+297
View File
@@ -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)
}
}
}
+74 -2
View File
@@ -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.
+107
View File
@@ -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
}
+13
View File
@@ -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
+53
View File
@@ -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)
}
}
+84 -3
View File
@@ -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.