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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user