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
+456 -14
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)
// 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.
+43 -9
View File
@@ -174,7 +174,7 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: normal\r\n\r\nhi"), "<a@example.com>", "a@example.com", "normal"); err != nil {
t.Fatal(err)
}
if _, err := store.StoreMessage(mailboxID, "Spam", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
if _, err := store.StoreMessage(mailboxID, "Junk", []byte("Subject: junk\r\n\r\nspam"), "<b@example.com>", "b@example.com", "junk"); err != nil {
t.Fatal(err)
}
@@ -201,12 +201,46 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
if err != nil {
t.Fatalf("list: %v", err)
}
var names []string
// LIST must report every standard folder (INBOX, Junk, Sent, Drafts, Trash) even
// though only INBOX and Junk actually hold a message here — a desktop client that
// only sees folders with existing mail never learns Trash/Drafts/Sent exist. See
// db.AllFoldersForMailbox / imapserver.Session.List.
byName := map[string]*imap.ListData{}
for _, m := range mailboxes {
byName[m.Mailbox] = m
}
wantFolders := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
if len(mailboxes) != len(wantFolders) {
names := make([]string, 0, len(mailboxes))
for _, m := range mailboxes {
names = append(names, m.Mailbox)
}
if len(names) != 2 {
t.Fatalf("expected 2 folders (INBOX, Spam), got %v", names)
t.Fatalf("expected folders %v, got %v", wantFolders, names)
}
for _, name := range wantFolders {
if _, ok := byName[name]; !ok {
t.Errorf("LIST is missing folder %q", name)
}
}
// Trash/Junk/Sent/Drafts should each carry their RFC 6154 SPECIAL-USE attribute
// so a desktop client (Thunderbird, Apple Mail, etc.) recognizes them regardless
// of the exact folder name.
wantAttrs := map[string]imap.MailboxAttr{
"Trash": imap.MailboxAttrTrash,
"Junk": imap.MailboxAttrJunk,
"Sent": imap.MailboxAttrSent,
"Drafts": imap.MailboxAttrDrafts,
}
for name, attr := range wantAttrs {
found := false
for _, a := range byName[name].Attrs {
if a == attr {
found = true
}
}
if !found {
t.Errorf("folder %q missing SPECIAL-USE attr %q, got %v", name, attr, byName[name].Attrs)
}
}
inboxData, err := client.Select("INBOX", nil).Wait()
@@ -217,20 +251,20 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
t.Fatalf("INBOX NumMessages = %d, want 1", inboxData.NumMessages)
}
spamData, err := client.Select("Spam", nil).Wait()
spamData, err := client.Select("Junk", nil).Wait()
if err != nil {
t.Fatalf("select Spam: %v", err)
t.Fatalf("select Junk: %v", err)
}
if spamData.NumMessages != 1 {
t.Fatalf("Spam NumMessages = %d, want 1", spamData.NumMessages)
t.Fatalf("Junk NumMessages = %d, want 1", spamData.NumMessages)
}
msgs, err := client.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{Envelope: true}).Collect()
if err != nil {
t.Fatalf("fetch in Spam: %v", err)
t.Fatalf("fetch in Junk: %v", err)
}
if len(msgs) != 1 || msgs[0].Envelope.Subject != "junk" {
t.Fatalf("expected the Spam-folder message (subject %q), got %+v", "junk", msgs)
t.Fatalf("expected the Junk-folder message (subject %q), got %+v", "junk", msgs)
}
}
+28 -5
View File
@@ -177,14 +177,37 @@ func (s *Session) Unsubscribe(mailbox string) error {
return nil
}
// List reports every folder that actually has mail (plus INBOX, always) — a filter
// rule's move_to_folder action is what creates a second folder; there's no IMAP
// CREATE/manual folder management.
// specialUseAttrs tags a folder with its RFC 6154 SPECIAL-USE attribute, if any —
// desktop IMAP clients (Thunderbird, Apple Mail, K-9, etc.) use this to recognize
// Trash/Junk/Sent/Drafts regardless of the exact folder name, though in practice not
// every client honors SPECIAL-USE reliably, which is why the folder itself is named
// "Junk" (see db.StandardMailboxFolders) rather than relying on this attribute alone.
func specialUseAttrs(folder string) []imap.MailboxAttr {
switch folder {
case "Trash":
return []imap.MailboxAttr{imap.MailboxAttrTrash}
case "Junk":
return []imap.MailboxAttr{imap.MailboxAttrJunk}
case "Sent":
return []imap.MailboxAttr{imap.MailboxAttrSent}
case "Drafts":
return []imap.MailboxAttr{imap.MailboxAttrDrafts}
default:
return nil
}
}
// List reports every folder the mailbox has: the standard folders (INBOX, Junk, Sent,
// Drafts, Trash — always, even empty) plus any custom folder a filter rule's
// move_to_folder action or explicit webmail folder creation has produced — the same
// full list db.AllFoldersForMailbox gives the webmail UI, so a desktop IMAP client
// sees exactly the same folders webmail does instead of only ones that happen to
// already hold a message (DistinctFoldersForMailbox alone).
func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
if err := s.requireAuth(); err != nil {
return err
}
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
folders, err := s.backend.DB.AllFoldersForMailbox(s.mailbox.ID)
if err != nil {
return err
}
@@ -199,7 +222,7 @@ func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string
if !goimapserver.MatchList(folder, '/', ref, pattern) {
continue
}
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil {
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/', Attrs: specialUseAttrs(folder)}); err != nil {
return err
}
}
+3 -3
View File
@@ -35,10 +35,10 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
case "move_to_folder":
return FilterAction{Folder: r.ActionValue}, nil
case "mark_as_spam":
// Reuses the same Spam folder score-based quarantine already delivers
// Reuses the same Junk folder score-based quarantine already delivers
// into (see smtpserver/session.go) — from the mailbox owner's
// perspective it's the same "goes to Spam" outcome either way.
return FilterAction{Folder: "Spam"}, nil
// perspective it's the same "goes to Junk" outcome either way.
return FilterAction{Folder: "Junk"}, nil
case "delete":
return FilterAction{Drop: true}, nil
case "mark_read":
+1 -1
View File
@@ -79,7 +79,7 @@ func TestApplyRulesMarkAsSpam(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if action.Folder != "Spam" {
if action.Folder != "Junk" {
t.Fatalf("expected mark_as_spam to route into the Spam folder, got %+v", action)
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ type Result struct {
ErrorCode string
ErrorMessage string
ServerResponse string
// Quarantined is true for a local delivery that landed in Spam rather than INBOX.
// Quarantined is true for a local delivery that landed in Junk rather than INBOX.
// Only ever set by smtpserver's local-delivery path (always false for outbound
// relay results) — it's the signal Data() uses to keep this message's body in the
// admin log despite content-logging otherwise being off by default, so a spam/
+1 -1
View File
@@ -68,7 +68,7 @@ func TestEmailLogBodyKeptWhenQuarantined(t *testing.T) {
t.Fatalf("send: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
if err != nil {
t.Fatal(err)
}
+3 -3
View File
@@ -70,7 +70,7 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) {
if err := send(t); err != nil {
t.Fatalf("expected delivery accepted (quarantined) with a zero reject threshold and no allow-list entry, got: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
if err != nil || len(spamMsgs) != 1 {
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
}
@@ -170,7 +170,7 @@ func TestFilterRuleMarkReadSetsSeenFlag(t *testing.T) {
func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Spam"); err != nil {
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Junk"); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
@@ -205,7 +205,7 @@ func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
if len(inbox) != 0 {
t.Fatalf("expected nothing in INBOX, found %d", len(inbox))
}
spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -58,7 +58,7 @@ func TestRspamdExplicitRejectActionStillHardRejects(t *testing.T) {
t.Fatal("expected delivery to be hard-rejected when rspamd's action is \"reject\"")
}
inboxMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
if len(inboxMsgs) != 0 || len(spamMsgs) != 0 {
t.Fatalf("expected nothing stored anywhere for a hard reject, got INBOX=%d Spam=%d", len(inboxMsgs), len(spamMsgs))
}
@@ -78,7 +78,7 @@ func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(t *testing.T) {
if err := sendTestMessage(t, addr, "hi"); err != nil {
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
if err != nil || len(spamMsgs) != 1 {
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
}
+5 -5
View File
@@ -368,7 +368,7 @@ func (s *Session) Data(r io.Reader) error {
// Privacy default: only headers (and the Subject field, logged separately below
// regardless) go into the admin-visible log, never the message itself — unless this
// sender/IP explicitly opted in via "Store Full Message Content" (storeMessage
// above), or the message was quarantined to Spam for at least one recipient, in
// above), or the message was quarantined to Junk for at least one recipient, in
// which case an admin genuinely needs to see it to judge a spam/abuse report. When
// stored, it's the *entire* raw message (not a plain-text extraction) so the log
// viewer can render the real HTML body, inline images, and attachments — re-parsed
@@ -447,7 +447,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
// (DNSBL hit, greylisting, etc.) worth still hard-rejecting at
// SMTP time to avoid backscatter; a bare score threshold hit
// (from either scorer) is quarantined instead of rejected, so a
// false positive is recoverable from the Spam folder rather than
// false positive is recoverable from the Junk folder rather than
// silently bounced with no trace.
if rAction == "reject" {
hardReject = true
@@ -463,14 +463,14 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
continue
}
if quarantine {
folder = "Spam"
folder = "Junk"
spamGated = true
}
}
// Filter rules organize legitimate mail the recipient already trusts arriving
// in their INBOX — a quarantined message skips them entirely and always lands
// in Spam, rather than a rule accidentally routing spam back into view.
// in Junk, rather than a rule accidentally routing spam back into view.
if !spamGated {
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
if err != nil {
@@ -503,7 +503,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
}
serverResponse := "Delivered to local mailbox"
if spamGated {
serverResponse = "Quarantined to Spam folder"
serverResponse = "Quarantined to Junk folder"
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated})
}
+1 -1
View File
@@ -49,7 +49,7 @@ func TestAdminRulesAddMultiConditionAndRenders(t *testing.T) {
if !strings.Contains(body, "to contains &#34;sales&#34;") || !strings.Contains(body, "AND") {
t.Fatalf("expected the rendered condition summary to show both AND'd conditions, got: %s", body)
}
if !strings.Contains(body, "Mark as Spam") {
if !strings.Contains(body, "Mark as Junk") {
t.Fatal("expected the mark_as_spam action to render")
}
}
+3
View File
@@ -86,6 +86,7 @@ func (a *App) funcMap() template.FuncMap {
},
"list": func(items ...string) []string { return items },
"isStandardFolder": isStandardFolder,
"folderIcon": folderIcon,
// emailOverallStatus mirrors the delivered/failed selectattr computation
// dashboard.html and logs.html both do in the Python templates.
"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
@@ -169,6 +170,7 @@ var standalonePages = []string{
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
"webmail_signatures.html",
// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
// injected into logs.html's full-screen modal — not a page anyone navigates to
// directly, so it doesn't need to look like a standalone document the way the
@@ -186,6 +188,7 @@ var standalonePages = []string{
// something that opens a popup of its own.
var pagesWithComposeWidget = []string{
"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
"webmail_signatures.html",
}
func hasComposeWidget(page string) bool {
+2 -2
View File
@@ -37,7 +37,7 @@
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Spam</option>
<option value="mark_as_spam">Mark as Junk</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
@@ -93,7 +93,7 @@
<td><code>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
@@ -23,6 +23,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
@@ -32,7 +33,7 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
+2 -1
View File
@@ -23,6 +23,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
@@ -33,7 +34,7 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
+61 -3
View File
@@ -61,7 +61,7 @@
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
@@ -83,6 +83,13 @@
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
{{if .signatures}}
<select class="form-select form-select-sm" id="signatureSelect" style="width: auto;" title="Signature">
<option value="">No signature</option>
{{range .signatures}}<option value="{{.ID}}" {{if eq $.default_signature_id .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{range .signatures}}<div class="sig-data" data-sig-id="{{.ID}}" style="display: none;">{{.ContentHTML | safe}}</div>{{end}}
{{end}}
<div class="ms-auto d-flex align-items-center gap-2">
{{if .send_as_options}}
<select class="form-select form-select-sm from-select" name="from">
@@ -175,18 +182,69 @@
],
},
});
// Signature tracking: the signature is kept as a plain-paragraph Delta range
// (index/length) rather than a DOM marker div. A wrapping <div> set directly on
// quill.root gets silently discarded by Quill's own MutationObserver within one
// update cycle (it isn't a recognized block type) — confirmed live, so signature
// content is inserted via Quill's own clipboard API instead, which turns it into
// real paragraph blots that survive.
var sigRange = null; // {index, length} of the signature currently in the editor, or null
function insertSignature(index, html) {
if (!html) return null;
const before = quill.getLength();
quill.clipboard.dangerouslyPasteHTML(index, html);
return { index: index, length: quill.getLength() - before };
}
function removeSignature() {
if (sigRange && sigRange.length > 0) quill.deleteText(sigRange.index, sigRange.length);
sigRange = null;
}
(function() {
const seed = document.getElementById('body_html_seed');
if (seed && seed.innerHTML.trim()) {
quill.root.innerHTML = seed.innerHTML;
if (!seed || !seed.innerHTML.trim()) return;
const parsed = document.createElement('div');
parsed.innerHTML = seed.innerHTML;
const sigEl = parsed.querySelector('#mailSignatureBlock');
let sigHTML = '';
if (sigEl) {
sigHTML = sigEl.innerHTML;
sigEl.remove();
}
quill.root.innerHTML = parsed.innerHTML;
// Reply/forward seeds start with an empty line above the quoted
// original (see composeCursorHome, webmail_compose.go) — put the
// cursor there so typing starts above the quote, not inside or after it.
quill.setSelection(0, 0);
quill.focus();
if (sigHTML) {
// The backend always inserts the signature immediately after that
// leading empty paragraph (index 1), before any quoted/forwarded
// text — see webmailComposeForm.
sigRange = insertSignature(1, sigHTML);
quill.setSelection(0, 0);
}
})();
// Signature picker: swaps the currently-inserted signature (tracked via
// sigRange above) for a different one, or removes it. Picking one when none
// exists yet appends it at the end of the body.
(function() {
const select = document.getElementById('signatureSelect');
if (!select) return;
const sigHTML = {};
document.querySelectorAll('.sig-data').forEach(function(el) { sigHTML[el.dataset.sigId] = el.innerHTML; });
select.addEventListener('change', function() {
const insertAt = sigRange ? sigRange.index : Math.max(0, quill.getLength() - 1);
removeSignature();
if (!select.value) return;
sigRange = insertSignature(insertAt, sigHTML[select.value] || '');
});
})();
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
// Cc) — clicking a toggle reveals its row and focuses the field, same as
// clicking "Cc"/"Bcc" in a real mail client.
+414 -64
View File
@@ -38,18 +38,34 @@
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
.msg-item:hover { background-color: #262626; }
.msg-item.active { background-color: #0d3860; }
.msg-item:has(.msg-check:checked) { background-color: #2a2f36; }
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
.msg-item.unread .msg-avatar { background: #0d6efd; }
.msg-main { min-width: 0; flex: 1 1 auto; }
.msg-row1 { display: flex; justify-content: space-between; gap: .5rem; }
.msg-row1 { display: flex; align-items: center; gap: .4rem; }
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; }
.msg-star { flex: 0 0 auto; cursor: pointer; color: #6a6a6a; }
.msg-star.bi-star-fill { color: #f4c150; }
.msg-star:hover { color: #f4c150; }
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; margin-left: auto; }
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
.msg-subject { color: #e0e0e0; }
#folderContextMenu { z-index: 1085; }
.folder-row { display: flex; align-items: center; }
.folder-drag-handle { cursor: grab; color: #6a6a6a; padding: 0 .25rem 0 0; flex: 0 0 auto; }
.folder-drag-handle:hover { color: #adb5bd; }
.folder-row.folder-row-dragging { opacity: 0.4; }
.folder-expand-toggle { flex: 0 0 auto; width: 16px; text-align: center; cursor: pointer; color: #8a8a8a; font-size: .8rem; transition: transform .1s; }
.folder-expand-toggle:hover { color: #fff; }
.folder-expand-toggle.collapsed { transform: rotate(-90deg); }
.folder-expand-spacer { flex: 0 0 auto; width: 16px; }
.folder-children { margin-left: 16px; }
.folder-children.collapsed { display: none; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
@@ -69,6 +85,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm" title="Signatures"><i class="bi bi-pen"></i></a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
@@ -78,7 +95,7 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
@@ -94,37 +111,30 @@
<div class="mail-sidebar" id="folderSidebarCol">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small text-uppercase">Folders</span>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="expandAllBtn" title="Expand all"><i class="bi bi-arrows-expand"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="collapseAllBtn" title="Collapse all"><i class="bi bi-arrows-collapse"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
</div>
<div class="list-group list-group-flush">
{{$active := .active_folder}}
{{$unread := .unread_counts}}
{{$counts := .folder_counts}}
{{range .folders}}
<div class="d-flex align-items-center folder-row">
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{$n := index $unread .}}
{{$total := index $counts .}}
{{if $total}}
<span class="badge {{if $n}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{$total}} total{{if $n}}, {{$n}} unread{{end}}">{{$total}}{{if $n}} / <strong>{{$n}}</strong>{{end}}</span>
{{end}}
</a>
{{if not (isStandardFolder .)}}
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
</form>
{{end}}
</div>
{{end}}
<div class="list-group list-group-flush" id="folderList">
{{range .folder_tree}}{{template "folderTreeNode" .}}{{end}}
</div>
<hr class="my-2">
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
</form>
<div class="text-muted small mt-2" style="font-size: .72rem;">Right-click a folder for more actions</div>
</div>
<div id="folderContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
<button type="button" class="dropdown-item" data-menu-action="new"><i class="bi bi-folder-plus me-2"></i>New folder</button>
<button type="button" class="dropdown-item" data-menu-action="mark-all-read"><i class="bi bi-envelope-open me-2"></i>Mark all as read</button>
<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil me-2"></i>Rename</button>
<button type="button" class="dropdown-item text-danger" data-menu-action="empty-trash"><i class="bi bi-trash3 me-2"></i>Empty Trash</button>
<button type="button" class="dropdown-item text-danger" data-menu-action="clean-spam"><i class="bi bi-shield-x me-2"></i>Clean up Junk</button>
<button type="button" class="dropdown-item" data-menu-action="restore-folder"><i class="bi bi-arrow-counterclockwise me-2"></i>Restore</button>
<button type="button" class="dropdown-item text-danger" data-menu-action="delete-folder-permanently"><i class="bi bi-trash3-fill me-2"></i>Delete permanently</button>
</div>
<form method="post" id="folderActionForm" class="d-none"></form>
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
<div class="mail-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
@@ -147,6 +157,7 @@
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
<a href="{{.starred_only_href}}" class="btn btn-sm border-0 py-0 {{if .starred_only}}btn-warning{{else}}btn-outline-secondary{{end}}" title="Favourites only"><i class="bi bi-star-fill"></i></a>
{{end}}
</div>
</div>
@@ -169,6 +180,7 @@
<div class="msg-main">
<div class="msg-row1">
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
<i class="bi msg-star {{if .Starred}}bi-star-fill{{else}}bi-star{{end}}" data-uid="{{.ID}}" data-folder="{{.Folder}}" title="{{if .Starred}}Remove from favourites{{else}}Add to favourites{{end}}"></i>
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
</div>
<div class="msg-row2">
@@ -223,6 +235,26 @@
</div>
</div>
<div class="modal fade" id="inputModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="inputModalForm">
<div class="modal-header">
<h5 class="modal-title" id="inputModalTitle"><i class="bi bi-pencil me-2"></i>Folder name</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control" id="inputModalField" maxlength="60" required>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="inputModalConfirm">OK</button>
</div>
</form>
</div>
</div>
</div>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
@@ -247,17 +279,53 @@
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
// showInputPrompt replaces the browser's native prompt() for folder
// name/rename input — a native prompt() blocks the whole tab (including any
// in-flight automation/extension driving it) until dismissed, and looks/feels
// inconsistent with the confirm dialog above. Resolves the entered text, or
// null if cancelled — same null-on-cancel contract prompt() itself has.
function showInputPrompt(title, defaultValue) {
return new Promise((resolve) => {
const modal = document.getElementById('inputModal');
const form = document.getElementById('inputModalForm');
const field = document.getElementById('inputModalField');
document.getElementById('inputModalTitle').textContent = title;
field.value = defaultValue || '';
let resolved = false;
const handleSubmit = (e) => {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
resolved = true;
resolve(field.value.trim());
bootstrap.Modal.getInstance(modal).hide();
cleanup();
};
const handleCancel = () => {
if (!resolved) resolve(null);
cleanup();
};
const cleanup = () => {
form.removeEventListener('submit', handleSubmit);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
form.addEventListener('submit', handleSubmit);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
modal.addEventListener('shown.bs.modal', function() { field.focus(); field.select(); }, { once: true });
});
}
// Delegated (not per-element) so this also covers [data-confirm] buttons
// injected later via fetch — the reading pane's own delete button, loaded
// into #readingPaneBody by openInPane, wouldn't exist yet at DOMContentLoaded
// time for a per-element listener to attach to.
document.addEventListener('click', async function(e) {
const button = e.target.closest('[data-confirm]');
if (!button) return;
e.preventDefault();
if (await showConfirmation(button.getAttribute('data-confirm'))) {
const form = button.closest('form');
if (form) form.submit();
}
});
});
});
// Drag a message row onto a folder in the sidebar to move it there — a
// shortcut for the toolbar's "Move to..." control. Each row carries its OWN
@@ -305,6 +373,114 @@
});
})();
// Drag-and-drop folder reordering — root folders (INBOX/Junk/Sent/Drafts/
// Trash) are static and never draggable; only a custom folder still under
// INBOX (server-computed .Renameable — see buildFolderView) can be dragged,
// and only among its OWN siblings (rows sharing the same immediate .folder-
// children parent — dragging never re-parents a folder, only reorders it
// where it already is). A dragged row's own .folder-children (if it has
// descendants) moves right along with it, or its whole subtree would get
// visually orphaned mid-drag. Reorders the DOM live as you drag (move the
// dragged row to whichever side of the row under the cursor it's closer to —
// a common no-library technique), persisting just that sibling group's final
// order once on dragend. draggingRow being set is what keeps this from firing
// during the unrelated "drag a message onto a folder" drag above (that one's
// own dragover guard checks draggedUID instead, so the two never interfere).
(function() {
const list = document.getElementById('folderList');
if (!list) return;
let draggingRow = null;
let draggingChildren = null;
function persistOrder(parentEl) {
const order = Array.from(parentEl.children)
.filter(function(el) { return el.classList.contains('folder-row'); })
.map(function(el) { return el.dataset.folder; });
const body = new URLSearchParams();
order.forEach(function(name) { body.append('order', name); });
body.set('csrf_token', window.__csrfToken || '');
fetch('/webmail/mail/folders/order', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
}
list.querySelectorAll('.folder-row[draggable="true"]').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
e.stopPropagation();
draggingRow = row;
draggingChildren = row.nextElementSibling && row.nextElementSibling.classList.contains('folder-children')
? row.nextElementSibling : null;
row.classList.add('folder-row-dragging');
});
row.addEventListener('dragend', function() {
row.classList.remove('folder-row-dragging');
if (draggingRow) persistOrder(draggingRow.parentElement);
draggingRow = null;
draggingChildren = null;
});
row.addEventListener('dragover', function(e) {
if (!draggingRow || draggingRow === row) return;
if (draggingRow.parentElement !== row.parentElement) return; // reorder only, never re-parent
e.preventDefault();
e.stopPropagation();
const rect = row.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
row.parentNode.insertBefore(draggingRow, before ? row : row.nextSibling);
if (draggingChildren) row.parentNode.insertBefore(draggingChildren, draggingRow.nextSibling);
});
});
})();
// Folder tree expand/collapse — a display preference remembered per-browser
// (localStorage, a Set of collapsed folder names), same pattern as the
// sidebar-hide toggle below. Default is fully expanded (nothing collapsed).
(function() {
const KEY = 'webmail_collapsed_folders';
function loadCollapsed() {
try { return new Set(JSON.parse(localStorage.getItem(KEY)) || []); } catch (e) { return new Set(); }
}
function saveCollapsed(set) { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); }
function setCollapsed(name, collapsed) {
const toggle = document.querySelector('[data-folder-toggle="' + CSS.escape(name) + '"]');
const kids = document.querySelector('[data-children-of="' + CSS.escape(name) + '"]');
if (toggle) { toggle.classList.toggle('collapsed', collapsed); toggle.title = collapsed ? 'Expand' : 'Collapse'; }
if (kids) kids.classList.toggle('collapsed', collapsed);
}
const collapsed = loadCollapsed();
collapsed.forEach(function(name) { setCollapsed(name, true); });
document.querySelectorAll('.folder-expand-toggle').forEach(function(toggle) {
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const name = toggle.dataset.folderToggle;
const nowCollapsed = !toggle.classList.contains('collapsed');
setCollapsed(name, nowCollapsed);
if (nowCollapsed) collapsed.add(name); else collapsed.delete(name);
saveCollapsed(collapsed);
});
});
const expandAllBtn = document.getElementById('expandAllBtn');
const collapseAllBtn = document.getElementById('collapseAllBtn');
if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) { setCollapsed(t.dataset.folderToggle, false); });
collapsed.clear();
saveCollapsed(collapsed);
});
if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) {
setCollapsed(t.dataset.folderToggle, true);
collapsed.add(t.dataset.folderToggle);
});
saveCollapsed(collapsed);
});
})();
// "+N more" toggle for a collapsed same-subject run — reveals every
// immediately-following row marked as part of that group, self-terminating
// at the first row that isn't (no need to track the count client-side).
@@ -343,37 +519,21 @@
});
})();
// Reading pane: clicking a row loads the message via fetch instead of
// navigating away, mirroring the admin dashboard's message-log modal. Drafts
// still navigate to compose (there's nothing to "read"). The clicked row is
// marked read optimistically client-side — the pane fetch itself is what
// actually marks it read server-side (see webmailMessagePane).
// Reading pane + selection share one block because Shift/Ctrl-click needs to
// work when clicking anywhere on a row, not just its small checkbox — a plain
// click opens the message in the reading pane; Shift-click range-selects from
// the last row you interacted with (open or select) to this one; Ctrl/Cmd-click
// toggles just this row. Any of those needs the full row list + index, not just
// the checkbox's own click event, so it can't be two independent IIFEs the way
// "load the pane" and "manage checkboxes" would otherwise naturally split.
(function() {
const paneBody = document.getElementById('readingPaneBody');
document.querySelectorAll('.msg-item').forEach(function(row) {
row.addEventListener('click', function(e) {
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
document.querySelectorAll('.msg-item.active').forEach(function(r) { r.classList.remove('active'); });
row.classList.add('active');
row.classList.remove('unread');
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { paneBody.innerHTML = html; })
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
});
});
})();
// Selection (checkboxes + select-all + Shift-click range) driving the bulk
// toolbar buttons — enabled only once something's actually selected.
(function() {
const checks = Array.from(document.querySelectorAll('.msg-check'));
const rows = Array.from(document.querySelectorAll('.msg-item'));
const checks = rows.map(function(row) { return row.querySelector('.msg-check'); });
const selectAll = document.getElementById('selectAllCheck');
const bulkBtns = document.querySelectorAll('.bulk-btn');
const moveSelect = document.getElementById('bulkMoveSelect');
let lastCheckedIndex = null;
let lastIndex = null;
function updateToolbar() {
const any = checks.some(function(c) { return c.checked; });
@@ -382,13 +542,48 @@
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
}
function openInPane(row) {
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
rows.forEach(function(r) { r.classList.remove('active'); });
row.classList.add('active');
row.classList.remove('unread');
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { paneBody.innerHTML = html; })
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
}
rows.forEach(function(row, i) {
row.addEventListener('click', function(e) {
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
if (e.shiftKey && lastIndex !== null) {
e.preventDefault();
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = true;
updateToolbar();
return;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
checks[i].checked = !checks[i].checked;
lastIndex = i;
updateToolbar();
return;
}
lastIndex = i;
openInPane(row);
});
});
checks.forEach(function(cb, i) {
cb.addEventListener('click', function(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const [from, to] = [lastCheckedIndex, i].sort(function(a, b) { return a - b; });
if (e.shiftKey && lastIndex !== null) {
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
}
lastCheckedIndex = i;
lastIndex = i;
updateToolbar();
});
});
@@ -437,7 +632,162 @@
});
}
})();
// Star toggle — click the star icon to flip \Flagged (see
// db.ToggleMessageStarred) without a full page reload. stopPropagation keeps
// this from also triggering the row's own click (which would open the reading
// pane / range-select, see the combined IIFE above).
document.querySelectorAll('.msg-star').forEach(function(star) {
star.addEventListener('click', async function(e) {
e.stopPropagation();
const uid = star.dataset.uid, folder = star.dataset.folder;
const body = new URLSearchParams();
body.set('csrf_token', window.__csrfToken || '');
const resp = await fetch(`/webmail/mail/${folder}/${uid}/star`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!resp.ok) return;
const nowStarred = star.classList.toggle('bi-star-fill');
star.classList.toggle('bi-star', !nowStarred);
star.title = nowStarred ? 'Remove from favourites' : 'Add to favourites';
});
});
// Folder sidebar context menu — right-click any folder for New folder / Mark
// all as read / Rename / Empty Trash, replacing the old always-visible "+ New
// folder" form. Every action posts through #folderActionForm (shared, its
// action/fields set right before submit) rather than one form per action, same
// reasoning as the message list's #bulkActionForm.
(function() {
const menu = document.getElementById('folderContextMenu');
const form = document.getElementById('folderActionForm');
let menuFolder = null;
// fields is a plain {name: value} object — each key becomes a hidden
// input, replacing whatever the previous action left behind.
function postAction(url, fields) {
form.action = url;
form.querySelectorAll('input[data-dynamic]').forEach(function(el) { el.remove(); });
const all = Object.assign({ csrf_token: window.__csrfToken || '' }, fields || {});
Object.keys(all).forEach(function(name) {
const input = document.createElement('input');
input.type = 'hidden'; input.name = name; input.value = all[name];
input.dataset.dynamic = '1';
form.appendChild(input);
});
form.submit();
}
function hideMenu() { menu.style.display = 'none'; menuFolder = null; }
document.querySelectorAll('.folder-row').forEach(function(row) {
row.addEventListener('contextmenu', function(e) {
e.preventDefault();
menuFolder = row.dataset.folder;
const renameable = row.dataset.renameable === 'true';
const isTrash = row.dataset.trash === 'true';
const isSpam = row.dataset.spam === 'true';
const underTrash = row.dataset.underTrash === 'true';
const canAddSubfolder = row.dataset.canAddSubfolder === 'true';
menu.querySelector('[data-menu-action="new"]').style.display = canAddSubfolder ? '' : 'none';
menu.querySelector('[data-menu-action="rename"]').style.display = renameable ? '' : 'none';
menu.querySelector('[data-menu-action="empty-trash"]').style.display = isTrash ? '' : 'none';
menu.querySelector('[data-menu-action="clean-spam"]').style.display = isSpam ? '' : 'none';
menu.querySelector('[data-menu-action="restore-folder"]').style.display = underTrash ? '' : 'none';
menu.querySelector('[data-menu-action="delete-folder-permanently"]').style.display = underTrash ? '' : 'none';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.style.display = 'block';
});
});
menu.querySelectorAll('[data-menu-action]').forEach(function(item) {
item.addEventListener('click', async function() {
const folder = menuFolder;
const action = item.dataset.menuAction;
hideMenu();
if (!folder) return;
switch (action) {
case 'new': {
const name = await showInputPrompt('New folder name (inside "' + folder + '")');
if (name) postAction('/webmail/mail/folders/add', { name: name, parent: folder });
break;
}
case 'mark-all-read':
postAction(`/webmail/mail/${folder}/mark-all-read`);
break;
case 'rename': {
const name = await showInputPrompt('Rename "' + folder + '" to', folder);
if (name && name !== folder) {
postAction(`/webmail/mail/folders/${folder}/rename`, { new_name: name });
}
break;
}
case 'empty-trash':
if (await showConfirmation('Permanently delete every message in Trash (and any folder deleted into it)? This can\'t be undone.')) {
postAction(`/webmail/mail/${folder}/empty`);
}
break;
case 'clean-spam':
if (await showConfirmation('Permanently delete every message in Junk? This can\'t be undone — they will NOT be moved to Trash.')) {
postAction(`/webmail/mail/${folder}/clean-spam`);
}
break;
case 'restore-folder':
postAction(`/webmail/mail/folders/${folder}/restore`);
break;
case 'delete-folder-permanently':
if (await showConfirmation('Permanently delete folder "' + folder + '" (and anything nested inside it)? This can\'t be undone.')) {
postAction(`/webmail/mail/folders/${folder}/delete-permanently`);
}
break;
}
});
});
document.addEventListener('click', function(e) {
if (!menu.contains(e.target)) hideMenu();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideMenu();
});
window.addEventListener('scroll', hideMenu, true);
})();
</script>
</body>
</html>
{{end}}
{{/* folderTreeNode renders one sidebar folder row plus (if any) its children,
recursing into itself for each child — . is a *folderViewNode (webmail_mail.go's
buildFolderView), which carries everything needed (icon, counts, active state,
renameable/can-add-child/is-spam/is-trash) precomputed, so no shared state needs
threading through the recursion beyond the node itself. */}}
{{define "folderTreeNode"}}
<div class="folder-row {{if .Children}}folder-has-children{{end}}" data-folder="{{.Name}}" data-renameable="{{.Renameable}}" data-can-add-subfolder="{{.CanAddKid}}" data-trash="{{.IsTrash}}" data-spam="{{.IsSpam}}" data-under-trash="{{.UnderTrash}}"{{if .Renameable}} draggable="true"{{end}}>
{{if .Children}}
<i class="bi bi-chevron-down folder-expand-toggle" data-folder-toggle="{{.Name}}" title="Collapse"></i>
{{else}}
<span class="folder-expand-spacer"></span>
{{end}}
{{if .Renameable}}<i class="bi bi-grip-vertical folder-drag-handle" title="Drag to reorder"></i>{{end}}
<a href="/webmail/mail/{{.Name}}" data-folder="{{.Name}}" draggable="false" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if .Active}}active{{end}}">
<span>{{if isStandardFolder .Name}}<i class="bi {{.Icon}} me-1"></i>{{end}}{{.Name}}</span>
{{if .Total}}
<span class="badge {{if .Unread}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{.Total}} total{{if .Unread}}, {{.Unread}} unread{{end}}">{{.Total}}{{if .Unread}} / <strong>{{.Unread}}</strong>{{end}}</span>
{{end}}
</a>
{{if .Renameable}}
<form method="post" action="/webmail/mail/folders/{{.Name}}/remove" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Delete folder" data-confirm="Delete folder &quot;{{.Name}}&quot;? Any mail in it moves to Trash, keeping this folder as a subfolder there so you can find it."><i class="bi bi-x-lg"></i></button>
</form>
{{end}}
</div>
{{if .Children}}
<div class="folder-children" data-children-of="{{.Name}}">
{{range .Children}}{{template "folderTreeNode" .}}{{end}}
</div>
{{end}}
{{end}}
@@ -24,6 +24,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
@@ -34,7 +35,7 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
@@ -137,8 +138,13 @@
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm">Move</button>
</form>
{{if .under_trash}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/restore">
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-counterclockwise me-1"></i>Restore</button>
</form>
{{end}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}</button>
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}</button>
</form>
</div>
</div>
@@ -15,9 +15,15 @@
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Move"><i class="bi bi-folder-symlink"></i></button>
</form>
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" onsubmit="return confirm('{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}');">
{{if .under_trash}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/restore">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Restore"><i class="bi bi-arrow-counterclockwise"></i></button>
</form>
{{end}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
@@ -17,7 +17,7 @@
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
+4 -3
View File
@@ -23,6 +23,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
@@ -33,7 +34,7 @@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
@@ -80,7 +81,7 @@
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Spam</option>
<option value="mark_as_spam">Mark as Junk</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
@@ -136,7 +137,7 @@
<td><code>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
@@ -0,0 +1,149 @@
{{define "webmail_signatures.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signatures - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.sig-preview { background-color: #fff; color: #000; border-radius: 6px; padding: .75rem; max-height: 140px; overflow: hidden; }
#sigEditor { height: 180px; background-color: #fff; color: #000; }
.ql-toolbar.ql-snow { background-color: #333; border-color: #404040; border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
.ql-container.ql-snow { border-color: #404040; border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
.ql-snow .ql-stroke { stroke: #c8c8c8; }
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
.ql-snow .ql-picker { color: #c8c8c8; }
.ql-snow .ql-picker-options { background-color: #2d2d2d; border-color: #404040; }
.ql-snow .ql-picker-item { color: #c8c8c8; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/signatures" class="btn btn-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
</div>
</div>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container pb-5">
<h2 class="mb-4"><i class="bi bi-pen me-2"></i>Signatures</h2>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0">Your signatures</h5></div>
<div class="card-body">
{{if .signatures}}
{{range .signatures}}
<div class="border rounded p-2 mb-3" style="border-color: #404040 !important;">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{.Name}}</strong>
{{if .IsDefaultNew}}<span class="badge bg-primary ms-1">Default: New</span>{{end}}
{{if .IsDefaultReply}}<span class="badge bg-info text-dark ms-1">Default: Reply/Forward</span>{{end}}
</div>
<div class="btn-group btn-group-sm">
<a href="/webmail/signatures?edit={{.ID}}" class="btn btn-outline-secondary" title="Edit"><i class="bi bi-pencil"></i></a>
<form method="post" action="/webmail/signatures/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete signature &quot;{{.Name}}&quot;?');">
<button type="submit" class="btn btn-outline-danger" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
<div class="sig-preview mb-2">{{.ContentHTML | safe}}</div>
<div class="btn-group btn-group-sm">
<form method="post" action="/webmail/signatures/{{if .IsDefaultNew}}0{{else}}{{.ID}}{{end}}/default?which=new" class="d-inline">
<button type="submit" class="btn {{if .IsDefaultNew}}btn-primary{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultNew}}✓ Default for new{{else}}Use for new{{end}}</button>
</form>
<form method="post" action="/webmail/signatures/{{if .IsDefaultReply}}0{{else}}{{.ID}}{{end}}/default?which=reply" class="d-inline">
<button type="submit" class="btn {{if .IsDefaultReply}}btn-info text-dark{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultReply}}✓ Default for reply/forward{{else}}Use for reply/forward{{end}}</button>
</form>
</div>
</div>
{{end}}
{{else}}
<p class="text-muted mb-0">No signatures yet — add one to have it available in compose.</p>
{{end}}
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0">{{if .editing}}Edit signature{{else}}New signature{{end}}</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/signatures/save" id="sigForm">
<input type="hidden" name="id" value="{{if .editing}}{{.editing.ID}}{{end}}">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{if .editing}}{{.editing.Name}}{{end}}" placeholder="e.g. Work, Personal" required>
</div>
<div class="mb-3">
<label class="form-label">Content</label>
<div id="sigEditor"></div>
<textarea name="content_html" style="display: none;"></textarea>
<div id="sig_html_seed" style="display: none;">{{if .editing}}{{.editing.ContentHTML}}{{end}}</div>
<div class="form-text">Plain text works fine too — just don't use the formatting toolbar. A blank signature is also valid (e.g. to temporarily disable one without deleting it).</div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
{{if .editing}}<a href="/webmail/signatures" class="btn btn-outline-secondary">Cancel</a>{{end}}
</form>
</div>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/quill/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
var sigQuill = new Quill('#sigEditor', {
theme: 'snow',
modules: { toolbar: [['bold', 'italic', 'underline'], [{ color: [] }], ['link', 'image'], ['clean']] },
});
(function() {
const seed = document.getElementById('sig_html_seed');
if (seed && seed.innerHTML.trim()) sigQuill.root.innerHTML = seed.innerHTML;
})();
document.getElementById('sigForm').addEventListener('submit', function() {
document.querySelector('[name="content_html"]').value = sigQuill.root.innerHTML;
});
</script>
</body>
</html>
{{end}}
+87
View File
@@ -451,6 +451,93 @@ func TestWebmailComposeReplyPrefill(t *testing.T) {
}
}
// extractSeed pulls out #body_html_seed's own content from a rendered compose page —
// the seed div can itself contain a nested <div> (a signature's wrapper), so its end
// is located via the exact markup immediately following the seed div's closing tag in
// webmail_compose.html, not just "the next </div>" (which would match the nested one).
func extractSeed(t *testing.T, page string) string {
t.Helper()
const openMarker = `id="body_html_seed" style="display: none;">`
const closeMarker = "</div>\n </form>"
start := strings.Index(page, openMarker)
if start < 0 {
t.Fatal("body_html_seed not found in rendered page")
}
start += len(openMarker)
end := strings.Index(page[start:], closeMarker)
if end < 0 {
t.Fatal("body_html_seed's closing markup not found in rendered page")
}
return page[start : start+end]
}
// TestWebmailComposeDefaultSignatureInsertion confirms a mailbox's default-for-new
// signature is auto-inserted into a brand-new compose, and its (possibly different)
// default-for-reply signature is auto-inserted into a reply — both above the quoted
// original, matching where a real signature belongs.
func TestWebmailComposeDefaultSignatureInsertion(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sigcompose@example.com", domains[0].ID, "sigcompose-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
newSigID, err := app.DB.CreateSignature(mailboxID, "New", "<p>Sent from my desk</p>")
if err != nil {
t.Fatal(err)
}
replySigID, err := app.DB.CreateSignature(mailboxID, "Reply", "<p>Sent on the go</p>")
if err != nil {
t.Fatal(err)
}
if err := app.DB.SetDefaultSignature(mailboxID, newSigID, false); err != nil {
t.Fatal(err)
}
if err := app.DB.SetDefaultSignature(mailboxID, replySigID, true); err != nil {
t.Fatal(err)
}
// Brand-new compose gets the "new" default.
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
// The picker's hidden .sig-data divs legitimately contain every signature's HTML
// (so the JS can swap between them client-side) — only the seed div (what actually
// loads into the editor) should be checked for which one was auto-inserted. The
// seed div's own content can itself contain a nested <div> (the signature's
// wrapper), so its end is found via the exact markup that follows the seed div's
// closing tag in the template, not just "the next </div>".
seed := extractSeed(t, body)
if !strings.Contains(seed, "Sent from my desk") {
t.Errorf("expected the default-for-new signature inserted, got seed:\n%s", seed)
}
if strings.Contains(seed, "Sent on the go") {
t.Error("did not expect the reply signature in a brand-new compose's seed")
}
// Reply gets the "reply" default instead, above the quote.
raw := "From: original@example.com\r\nTo: sigcompose@example.com\r\nSubject: Hi\r\nMessage-Id: <o1@example.com>\r\n\r\noriginal body"
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "o1@example.com", "original@example.com", "Hi")
if err != nil {
t.Fatal(err)
}
replyReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?reply="+strconv.FormatInt(uid, 10)+"&folder=INBOX", nil)
replyReq.AddCookie(cookie)
replyRec := httptest.NewRecorder()
mux.ServeHTTP(replyRec, replyReq)
replySeed := extractSeed(t, replyRec.Body.String())
if !strings.Contains(replySeed, "Sent on the go") {
t.Errorf("expected the default-for-reply signature inserted into the seed, got:\n%s", replySeed)
}
sigIdx := strings.Index(replySeed, "Sent on the go")
quoteIdx := strings.Index(replySeed, "wrote:")
if sigIdx < 0 || quoteIdx < 0 || sigIdx > quoteIdx {
t.Error("expected the signature above the quoted original")
}
}
// TestWebmailMoveAndDeleteMessage confirms moving a message changes its folder, and
// deleting from a non-Trash folder moves to Trash first, requiring a second delete to
// actually remove it.
+43 -1
View File
@@ -40,7 +40,28 @@ func (a *App) composeFormData(mbox *db.Mailbox) M {
}
identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
return M{"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts}
signatures, _ := a.DB.ListSignatures(mbox.ID)
return M{
"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts,
"signatures": signatures,
// Always present (never a missing map key) so the template's {{eq
// $.default_signature_id .ID}} comparison never has to handle a nil operand —
// 0 is a safe "no default" sentinel since real ids start at 1 (AUTOINCREMENT).
"default_signature_id": int64(0),
}
}
// signatureMarkerID wraps every auto-inserted (or picker-inserted) signature's HTML in
// a stable-id div — the compose page's JS looks for this element to swap the
// signature in place when the user picks a different one, instead of needing to
// somehow diff/guess which part of their freely-edited body is "the signature".
const signatureMarkerID = "mailSignatureBlock"
func wrapSignatureHTML(contentHTML string) string {
if strings.TrimSpace(contentHTML) == "" {
return ""
}
return `<div id="` + signatureMarkerID + `">` + contentHTML + `</div>`
}
// webmailComposeForm shows the compose page, optionally prefilled for a reply,
@@ -99,6 +120,27 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
}
}
// Signature auto-insertion: reply/forward gets the reply/forward default, a
// brand-new compose (mode == "") gets the new-message default. A reloaded draft
// is left exactly as saved — it may already contain whatever signature was there
// when it was saved, or none, and re-adding one here would duplicate it.
if mode != "draft" {
forReply := mode == "reply" || mode == "replyall" || mode == "forward"
if sig, err := a.DB.GetDefaultSignature(mbox.ID, forReply); err == nil && sig != nil {
data["default_signature_id"] = sig.ID
if wrapped := wrapSignatureHTML(sig.ContentHTML); wrapped != "" {
existing, _ := data["body_html"].(template.HTML)
body := string(existing)
if strings.HasPrefix(body, composeCursorHome) {
body = composeCursorHome + wrapped + strings.TrimPrefix(body, composeCursorHome)
} else {
body = composeCursorHome + wrapped + body
}
data["body_html"] = template.HTML(body)
}
}
}
a.render(w, r, "webmail_compose.html", data)
}
+17 -9
View File
@@ -75,10 +75,12 @@ func TestWebmailFolderCreateRejectsStandardAndDuplicateNames(t *testing.T) {
}
}
// TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard confirms
// deleting a custom folder relocates its messages to INBOX, and that a standard
// folder can't be deleted via the same route even if requested directly.
func TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard(t *testing.T) {
// TestWebmailFolderDeleteMovesToTrashAndCannotDeleteStandard confirms deleting a
// custom folder re-parents it (and its messages, untouched under the same flat name)
// under Trash rather than relocating messages to INBOX — the folder itself becomes a
// subfolder of Trash, not gone — and that a standard folder can't be deleted via the
// same route even if requested directly.
func TestWebmailFolderDeleteMovesToTrashAndCannotDeleteStandard(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
@@ -101,13 +103,19 @@ func TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard(t *testi
t.Fatalf("delete folder: status=%d", rec.Code)
}
remaining, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected the folder record gone, got %v (err=%v)", remaining, err)
root, err := app.DB.FolderRoot(mailboxID, "Newsletters")
if err != nil || root != "Trash" {
t.Fatalf("expected Newsletters to now resolve under Trash, got root=%q (err=%v)", root, err)
}
// The message stays tagged with its own flat folder name, not moved to "Trash" —
// that's what makes it show up nested under Trash instead of dumped at its root.
msgs, err := app.DB.ListMessagesInFolder(mailboxID, "Newsletters")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected the message to stay in Newsletters, got %d (err=%v)", len(msgs), err)
}
inboxMsgs, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil || len(inboxMsgs) != 1 {
t.Fatalf("expected the message relocated to INBOX, got %d (err=%v)", len(inboxMsgs), err)
if err != nil || len(inboxMsgs) != 0 {
t.Fatalf("expected nothing relocated to INBOX, got %d (err=%v)", len(inboxMsgs), err)
}
// Attempting to delete a standard folder must be rejected, not silently succeed.
+450 -67
View File
@@ -36,32 +36,11 @@ var htmlBodyPolicy = func() *bluemonday.Policy {
// quote a plain-text-only original message's body when replying/forwarding.
var plainTextPolicy = bluemonday.StrictPolicy()
// standardMailFolders are always shown in the folder sidebar even when empty — the
// rest of a mailbox's folder list is whatever filter-rule move_to_folder actions (or,
// later, explicit folder creation) have actually produced messages in.
var standardMailFolders = []string{"INBOX", "Spam", "Sent", "Drafts", "Trash"}
func mergeFolders(custom []string) []string {
seen := make(map[string]bool, len(standardMailFolders)+len(custom))
out := make([]string, 0, len(standardMailFolders)+len(custom))
for _, f := range standardMailFolders {
seen[f] = true
out = append(out, f)
}
for _, f := range custom {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// isStandardFolder reports whether name is one of the built-in folders every mailbox
// always has — these can never be created, renamed, or deleted through the folder
// management UI.
// management UI, and never move (see db.StandardMailboxFolders).
func isStandardFolder(name string) bool {
for _, f := range standardMailFolders {
for _, f := range db.StandardMailboxFolders {
if f == name {
return true
}
@@ -69,20 +48,69 @@ func isStandardFolder(name string) bool {
return false
}
// allFoldersFor is the full folder list for a mailbox: standard folders, 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.
// allFoldersFor is the full folder list for a mailbox — see db.AllFoldersForMailbox.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
fromMessages, err := a.DB.DistinctFoldersForMailbox(mailboxID)
if err != nil {
return nil, err
return a.DB.AllFoldersForMailbox(mailboxID)
}
// folderIcon picks a sidebar icon (a Bootstrap Icons class) that matches what each
// standard folder actually is, instead of the same generic folder glyph for all of
// them — INBOX and every custom folder keep the generic one.
func folderIcon(name string) string {
switch name {
case "Trash":
return "bi-trash3"
case "Sent":
return "bi-send"
case "Drafts":
return "bi-journal-text"
case "Junk":
return "bi-shield-exclamation"
case "INBOX":
return "bi-inbox"
default:
return "bi-folder2"
}
explicit, err := a.DB.ListMailboxFolders(mailboxID)
if err != nil {
return nil, err
}
// folderViewNode is a fully-resolved sidebar tree node — everything
// webmail_folder.html's recursive "folderTreeNode" template needs for one folder,
// computed once here rather than threaded (unread/counts/active-folder) through every
// level of the recursive {{template}} call, which only ever gets to pass a single `.`
// value down.
type folderViewNode struct {
Name string
Icon string
Total int
Unread int
Active bool
Renameable bool // also gates showing the remove button and drag-and-drop reordering
CanAddKid bool
IsSpam bool // gates the "Clean up Junk" context-menu item
IsTrash bool // gates the "Empty Trash" context-menu item
UnderTrash bool // gates "Delete permanently" / "Restore" for a folder already in Trash
Children []*folderViewNode
}
// buildFolderView resolves db.FolderTree's nodes into folderViewNodes for one render.
func buildFolderView(nodes []*db.FolderNode, active string, unread, counts map[string]int) []*folderViewNode {
var build func(n *db.FolderNode) *folderViewNode
build = func(n *db.FolderNode) *folderViewNode {
v := &folderViewNode{
Name: n.Name, Icon: folderIcon(n.Name), Total: counts[n.Name], Unread: unread[n.Name],
Active: n.Name == active, Renameable: n.Renameable, CanAddKid: n.CanAddKid,
IsSpam: n.Name == "Junk", IsTrash: n.Name == "Trash", UnderTrash: n.UnderTrash,
}
return mergeFolders(append(fromMessages, explicit...)), nil
for _, c := range n.Children {
v.Children = append(v.Children, build(c))
}
return v
}
out := make([]*folderViewNode, 0, len(nodes))
for _, n := range nodes {
out = append(out, build(n))
}
return out
}
// folderRow adds template-ready fields to a listed message so webmail_folder.html
@@ -90,6 +118,7 @@ func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
type folderRow struct {
db.MailboxMessage
Unread bool
Starred bool
// GroupExtra is set on the newest row of a same-subject run: how many older
// messages are collapsed under it (0 = not part of a group). Collapsed is set on
// each of those older rows, which the template hides until the group's expand
@@ -100,9 +129,9 @@ type folderRow struct {
// sortLink builds the href for a clickable "From"/"Date" column header: clicking an
// inactive column sorts by it descending; clicking the already-active column flips
// direction; unreadOnly (and folder/query, via the caller building this against the
// current URL) carries over so toggling sort never drops the unread filter.
func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) string {
// direction; unreadOnly/starredOnly (and folder/query, via the caller building this
// against the current URL) carry over so toggling sort never drops another filter.
func sortLink(col string, unreadOnly, starredOnly bool, activeSortBy, activeSortDir string) string {
v := url.Values{}
dir := "desc"
if activeSortBy == col {
@@ -121,6 +150,9 @@ func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) s
if unreadOnly {
v.Set("unread", "1")
}
if starredOnly {
v.Set("starred", "1")
}
if encoded := v.Encode(); encoded != "" {
return "?" + encoded
}
@@ -136,6 +168,18 @@ func isUnread(flags string) bool {
return true
}
// isStarred reports whether flags includes \Flagged — IMAP's standard "important/
// starred" flag, reused as-is (see db.ToggleMessageStarred) rather than a new schema
// column, so a desktop IMAP client's own star button and webmail's stay in sync.
func isStarred(flags string) bool {
for _, f := range strings.Fields(flags) {
if f == `\Flagged` {
return true
}
}
return false
}
// normalizeSubjectForGrouping strips Re:/Fwd:/Fw: prefixes and case for comparison.
// This is subject-based grouping, not References/In-Reply-To thread reconstruction
// — a reply with a hand-edited subject line won't group with its original, and two
@@ -205,6 +249,10 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
if err != nil {
a.Logger.Error("list folders for mailbox %d: %v", mbox.ID, err)
}
folderTreeRaw, err := a.DB.FolderTree(mbox.ID)
if err != nil {
a.Logger.Error("build folder tree for mailbox %d: %v", mbox.ID, err)
}
unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
@@ -213,6 +261,7 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
if err != nil {
a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
}
folderTree := buildFolderView(folderTreeRaw, folder, unreadCounts, folderCounts)
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
@@ -220,6 +269,10 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
offset := (page - 1) * webmailPageSize
unreadOnly := r.URL.Query().Get("unread") == "1"
// Starred filtering, like sort/grouping, only applies to a plain single-folder
// view — SearchMessagesInFolder has no starred param (a search result can span
// folders and is a much rarer thing to also want starred-filtered).
starredOnly := query == "" && r.URL.Query().Get("starred") == "1"
sortBy := r.URL.Query().Get("sort") // "" (id/date, default) or "from"
sortDir := r.URL.Query().Get("dir") // "" (desc, default) or "asc"
@@ -232,18 +285,18 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
} else {
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly)
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly, starredOnly)
if err != nil {
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
}
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, sortBy, sortDir, offset, webmailPageSize)
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, starredOnly, sortBy, sortDir, offset, webmailPageSize)
}
if err != nil {
setFlash(w, "error", "Error loading messages")
}
messages := make([]folderRow, 0, len(rows))
for _, m := range rows {
messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags)})
messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags), Starred: isStarred(m.Flags)})
}
// Grouping a cross-folder search's results by subject would mix messages that
// happen to share a subject across unrelated folders — only group a real,
@@ -257,6 +310,9 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
if !unreadOnly {
unreadToggleV.Set("unread", "1")
}
if starredOnly {
unreadToggleV.Set("starred", "1")
}
if sortBy != "" {
unreadToggleV.Set("sort", sortBy)
}
@@ -265,12 +321,30 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
unreadOnlyHref := "?" + unreadToggleV.Encode()
starredToggleV := url.Values{}
if !starredOnly {
starredToggleV.Set("starred", "1")
}
if unreadOnly {
starredToggleV.Set("unread", "1")
}
if sortBy != "" {
starredToggleV.Set("sort", sortBy)
}
if sortDir != "" {
starredToggleV.Set("dir", sortDir)
}
starredOnlyHref := "?" + starredToggleV.Encode()
pageHref := func(n int) string {
v := url.Values{}
v.Set("page", strconv.Itoa(n))
if unreadOnly {
v.Set("unread", "1")
}
if starredOnly {
v.Set("starred", "1")
}
if sortBy != "" {
v.Set("sort", sortBy)
}
@@ -281,14 +355,15 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
}
a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"mailbox": mbox, "folders": folders, "folder_tree": folderTree, "active_folder": folder,
"messages": messages, "page": page, "total": total,
"has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
"unread_only": unreadOnly, "sort_by": sortBy, "sort_dir": sortDir,
"sort_from_href": sortLink("from", unreadOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, sortBy, sortDir),
"unread_only": unreadOnly, "starred_only": starredOnly, "sort_by": sortBy, "sort_dir": sortDir,
"sort_from_href": sortLink("from", unreadOnly, starredOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, starredOnly, sortBy, sortDir),
"unread_only_href": unreadOnlyHref,
"starred_only_href": starredOnlyHref,
"prev_href": pageHref(page - 1),
"next_href": pageHref(page + 1),
"flashes": popFlashes(w, r),
@@ -334,9 +409,18 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
// Whether this message is already somewhere under Trash (literally "Trash", or a
// folder that was itself deleted into Trash) — the delete button's wording/action
// and whether a Restore button appears both depend on this, not just a literal
// `folder == "Trash"` check (see webmailMessageDelete for the same distinction on
// the backend side).
underTrash := false
if root, err := a.DB.FolderRoot(mbox.ID, folder); err == nil {
underTrash = root == "Trash"
}
return M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"mailbox": mbox, "folders": folders, "active_folder": folder, "under_trash": underTrash,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
}, true
@@ -398,8 +482,10 @@ func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.
return msg, true
}
// webmailMessageDelete moves a message to Trash — or, if it's already in Trash,
// permanently deletes it (ciphertext, index row, and frees the quota).
// webmailMessageDelete moves a message to Trash — or, if it's already somewhere under
// Trash (literally "Trash", or a folder that was itself deleted into Trash — see
// webmailDeleteFolder), permanently deletes it (ciphertext, index row, and frees the
// quota).
func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
@@ -408,7 +494,7 @@ func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
return
}
if folder == "Trash" {
if root, err := a.DB.FolderRoot(mbox.ID, folder); err == nil && root == "Trash" {
if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error deleting message")
} else {
@@ -417,7 +503,7 @@ func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
if err := a.DB.MoveMessage(mbox.ID, uid, "Trash"); err != nil {
if err := a.DB.MoveMessageToTrash(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error moving message to Trash")
} else {
setFlash(w, "success", "Message moved to Trash")
@@ -425,6 +511,29 @@ func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailRestoreMessage moves a message back to the folder it was in before it was
// trashed (or INBOX if that's unknown — see db.RestoreMessage). Only a message
// currently somewhere under Trash can be restored this way.
func (a *App) webmailRestoreMessage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
if root, err := a.DB.FolderRoot(mbox.ID, folder); err != nil || root != "Trash" {
setFlash(w, "error", "Only a message in Trash can be restored")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
if err := a.DB.RestoreMessage(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error restoring message")
} else {
setFlash(w, "success", "Message restored")
}
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
}
// webmailMessageMove reassigns a message to a different (existing or freshly named)
// folder, e.g. from the message view's "Move to..." control.
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
@@ -487,11 +596,13 @@ func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
var err error
switch action {
case "delete":
if folder == "Trash" {
if root, rootErr := a.DB.FolderRoot(mbox.ID, folder); rootErr == nil && root == "Trash" {
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
} else {
err = a.DB.MoveMessage(mbox.ID, uid, "Trash")
err = a.DB.MoveMessageToTrash(mbox.ID, uid)
}
case "restore":
err = a.DB.RestoreMessage(mbox.ID, uid)
case "move":
err = a.DB.MoveMessage(mbox.ID, uid, target)
case "read":
@@ -546,12 +657,21 @@ func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request)
const maxFolderNameLen = 60
// webmailAddFolder creates a new custom folder from the sidebar's "+ New folder"
// form. A standard folder name, an empty name, or a name that already exists is
// rejected with a flash rather than silently accepted/ignored.
// webmailAddFolder creates a new custom folder as a child of the folder the sidebar's
// context menu was opened on (see webmail_folder.html's "New folder" — always the
// right-clicked node, INBOX or an existing custom folder still under it; the request
// itself is re-validated here, never trusted just because the menu item was hidden
// client-side elsewhere). A standard folder name, an empty name, a name containing
// "/", or a name that already exists anywhere in the mailbox (folder names are unique
// per mailbox regardless of nesting — messages reference a folder by that name alone)
// is rejected with a flash rather than silently accepted/ignored.
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := strings.TrimSpace(r.FormValue("name"))
parent := strings.TrimSpace(r.FormValue("parent"))
if parent == "" {
parent = "INBOX"
}
fail := func(msg string) {
setFlash(w, "error", msg)
@@ -564,6 +684,9 @@ func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
case len(name) > maxFolderNameLen:
fail("Folder name is too long")
return
case strings.Contains(name, "/"):
fail("Folder name can't contain \"/\"")
return
case isStandardFolder(name):
fail(name + " already exists")
return
@@ -573,13 +696,26 @@ func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
fail("Error creating folder")
return
}
found := false
for _, f := range existing {
if strings.EqualFold(f, name) {
fail("A folder named " + f + " already exists")
return
}
if strings.EqualFold(f, parent) {
parent = f
found = true
}
if err := a.DB.CreateMailboxFolder(mbox.ID, name); err != nil {
}
if !found {
fail("Folder no longer exists")
return
}
if root, err := a.DB.FolderRoot(mbox.ID, parent); err != nil || root != "INBOX" {
fail("New folders can only go under Inbox")
return
}
if err := a.DB.CreateMailboxFolderUnder(mbox.ID, name, parent); err != nil {
fail("Error creating folder")
return
}
@@ -587,10 +723,14 @@ func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}
// webmailDeleteFolder removes a custom folder, moving any messages still in it to
// INBOX first — a folder is never left holding mail nothing can browse to anymore.
// Standard folders (checked server-side, not just hidden client-side) can't be
// removed this way.
// webmailDeleteFolder "deletes" a custom folder by re-parenting it (and, since its
// descendants reference it by row id rather than a materialized path, its whole
// subtree along with it) under Trash — its messages are never touched or relocated,
// they're still tagged with the same flat folder name(s) they always were, which now
// simply render nested under Trash instead of under Inbox. This is the only way a
// folder ever moves under Trash, precisely so Trash mirrors whatever structure was
// deleted from, instead of dumping everything into one flat pile. Standard folders
// (checked server-side, not just hidden client-side) can't be removed this way.
func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := r.PathValue("name")
@@ -600,15 +740,258 @@ func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.MoveAllMessagesInFolder(mbox.ID, name, "INBOX"); err != nil {
setFlash(w, "error", "Error removing folder")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
setFlash(w, "error", "Error removing folder")
if err := a.DB.MoveFolderToTrash(mbox.ID, name); err != nil {
setFlash(w, "error", "Error deleting folder")
} else {
setFlash(w, "success", "Folder "+name+" removed — any mail in it moved to INBOX")
setFlash(w, "success", "Folder "+name+" moved to Trash")
}
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
// webmailRenameFolder renames a folder from the sidebar's right-click context menu —
// only a custom folder still under INBOX (not INBOX itself, not Junk/Sent/Drafts, and
// not a folder that's been deleted into Trash — that subtree is a historical record,
// not something to keep editing). A single-row name change is enough: children
// reference their parent by row id, not by name, so they stay correctly nested
// without any further writes (see db.RenameMailboxFolder).
func (a *App) webmailRenameFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
oldName := r.PathValue("name")
newName := strings.TrimSpace(r.FormValue("new_name"))
fail := func(msg string) {
setFlash(w, "error", msg)
http.Redirect(w, r, MailboxPrefix+"/mail/"+oldName, http.StatusFound)
}
if root, err := a.DB.FolderRoot(mbox.ID, oldName); err != nil || isStandardFolder(oldName) || root != "INBOX" {
fail(oldName + " can't be renamed")
return
}
switch {
case newName == "":
fail("Folder name is required")
return
case len(newName) > maxFolderNameLen:
fail("Folder name is too long")
return
case strings.Contains(newName, "/"):
fail("Folder name can't contain \"/\"")
return
case isStandardFolder(newName):
fail(newName + " already exists")
return
}
existing, err := a.allFoldersFor(mbox.ID)
if err != nil {
fail("Error renaming folder")
return
}
for _, f := range existing {
if strings.EqualFold(f, newName) && !strings.EqualFold(f, oldName) {
fail("A folder named " + f + " already exists")
return
}
}
if err := a.DB.RenameMailboxFolder(mbox.ID, oldName, newName); err != nil {
fail("Error renaming folder")
return
}
setFlash(w, "success", "Folder renamed to "+newName)
http.Redirect(w, r, MailboxPrefix+"/mail/"+newName, http.StatusFound)
}
// webmailMarkAllRead marks every unread message in one folder as read — the sidebar
// context menu's "Mark all as read", available on every folder including INBOX.
func (a *App) webmailMarkAllRead(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
if err := a.DB.MarkAllReadInFolder(mbox.ID, folder); err != nil {
setFlash(w, "error", "Error marking messages as read")
} else {
setFlash(w, "success", "All messages in "+folder+" marked as read")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// deleteFolderSubtreePermanently permanently deletes every message across start and
// every folder nested under it — ciphertext, index row, and freed quota, via the same
// per-message Mailstore.DeleteMessage path webmailMessageDelete already uses for a
// single already-in-Trash message — and removes the esrv_mailbox_folders records for
// whatever's now empty, so a permanently-deleted folder doesn't linger as an empty
// shell the way a merely-emptied one does. removeStartRecord controls whether start's
// OWN record is removed too: false for the 5 standard folders (Empty Trash/Clean up
// Junk — they always exist, nothing to remove even if a stray row happens to), true
// for a genuine custom folder someone is permanently deleting from within Trash.
func (a *App) deleteFolderSubtreePermanently(mbox *db.Mailbox, start string, removeStartRecord bool) (int, error) {
names, err := a.DB.FolderSubtreeNames(mbox.ID, start)
if err != nil {
return 0, err
}
n := 0
for _, name := range names {
rows, err := a.DB.ListMessagesInFolder(mbox.ID, name)
if err != nil {
a.Logger.Error("list messages in %s for mailbox %d: %v", name, mbox.ID, err)
continue
}
for _, m := range rows {
if err := a.Mailstore.DeleteMessage(mbox.ID, m.ID); err != nil {
a.Logger.Error("permanently delete message %d for mailbox %d: %v", m.ID, mbox.ID, err)
continue
}
n++
}
}
toRemove := make([]string, 0, len(names))
for _, name := range names {
if name == start && !removeStartRecord {
continue
}
toRemove = append(toRemove, name)
}
if err := a.DB.DeleteFolderRows(mbox.ID, toRemove); err != nil {
a.Logger.Error("remove folder records for mailbox %d: %v", mbox.ID, err)
}
return n, nil
}
// webmailEmptyTrash permanently deletes every message in Trash and anything deleted
// into it, and removes those now-empty folder records too (see
// deleteFolderSubtreePermanently) — Trash-only, enforced server-side (the sidebar
// only ever shows this action on Trash, but the route itself doesn't trust that).
func (a *App) webmailEmptyTrash(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
if folder != "Trash" {
setFlash(w, "error", "Only Trash can be emptied")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
n, err := a.deleteFolderSubtreePermanently(mbox, "Trash", false)
if err != nil {
setFlash(w, "error", "Error emptying Trash")
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
setFlash(w, "success", fmt.Sprintf("Trash emptied (%d message(s) permanently deleted)", n))
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
}
// webmailCleanSpam permanently deletes every message in Junk — no move to Trash, no
// recovery (see deleteFolderSubtreePermanently). Junk-only, enforced server-side.
func (a *App) webmailCleanSpam(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
if folder != "Junk" {
setFlash(w, "error", "Only Junk can be cleaned up")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
n, err := a.deleteFolderSubtreePermanently(mbox, "Junk", false)
if err != nil {
setFlash(w, "error", "Error cleaning up Junk")
http.Redirect(w, r, MailboxPrefix+"/mail/Junk", http.StatusFound)
return
}
setFlash(w, "success", fmt.Sprintf("Junk cleaned up (%d message(s) permanently deleted)", n))
http.Redirect(w, r, MailboxPrefix+"/mail/Junk", http.StatusFound)
}
// webmailDeleteFolderPermanently permanently removes one specific folder that's
// already been deleted into Trash (and whatever's nested under it) — messages and
// all, no further move, no recovery. Only a folder currently under Trash can be
// removed this way (checked server-side); a folder still under INBOX goes through
// webmailDeleteFolder (move to Trash) instead — permanently deleting an active folder
// isn't offered directly, only after it's already been trashed once.
func (a *App) webmailDeleteFolderPermanently(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := r.PathValue("name")
if isStandardFolder(name) {
setFlash(w, "error", name+" can't be removed")
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
if root, err := a.DB.FolderRoot(mbox.ID, name); err != nil || root != "Trash" {
setFlash(w, "error", "Only a folder already in Trash can be permanently deleted")
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
n, err := a.deleteFolderSubtreePermanently(mbox, name, true)
if err != nil {
setFlash(w, "error", "Error deleting folder")
} else {
setFlash(w, "success", fmt.Sprintf("Folder %s permanently deleted (%d message(s))", name, n))
}
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
}
// webmailRestoreFolder puts a folder that's currently under Trash back where it was
// before it was deleted (or under INBOX if that's no longer known — see
// db.RestoreFolder). Only a folder currently under Trash can be restored this way.
func (a *App) webmailRestoreFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := r.PathValue("name")
if root, err := a.DB.FolderRoot(mbox.ID, name); err != nil || root != "Trash" {
setFlash(w, "error", "Only a folder in Trash can be restored")
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
if err := a.DB.RestoreFolder(mbox.ID, name); err != nil {
setFlash(w, "error", "Error restoring folder")
} else {
setFlash(w, "success", "Folder "+name+" restored")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}
// webmailToggleStar flips \Flagged on one message — the message list's star icon.
func (a *App) webmailToggleStar(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
http.NotFound(w, r)
return
}
if err := a.DB.ToggleMessageStarred(mbox.ID, uid); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// maxFolderOrderEntries bounds a single reorder request — the sidebar only ever
// shows a handful of folders, so a much larger list is either a stale client or
// something malformed, not a real drag-and-drop.
const maxFolderOrderEntries = 200
// webmailSetFolderOrder persists the sidebar's full drag-and-drop order — called
// once per drag, with the complete current folder list top to bottom (see
// db.SetFolderOrder). No redirect/flash: the client already reflects the order it
// just dragged into place, this call is purely to persist it for next page load.
func (a *App) webmailSetFolderOrder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
order := r.Form["order"]
if len(order) == 0 || len(order) > maxFolderOrderEntries {
w.WriteHeader(http.StatusBadRequest)
return
}
for i, name := range order {
order[i] = strings.TrimSpace(name)
if order[i] == "" || len(order[i]) > maxFolderNameLen {
w.WriteHeader(http.StatusBadRequest)
return
}
}
if err := a.DB.SetFolderOrder(mbox.ID, order); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+88
View File
@@ -0,0 +1,88 @@
package webui
import (
"net/http"
"strings"
"mailgoserver/internal/db"
)
// webmailSignaturesPage lists a mailbox's saved signatures and the add/edit form.
func (a *App) webmailSignaturesPage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
signatures, err := a.DB.ListSignatures(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading signatures")
}
// ?edit=<id> loads an existing signature into the form instead of a blank one.
var editing *db.MailboxSignature
if idStr := r.URL.Query().Get("edit"); idStr != "" {
editing, _ = a.DB.GetSignatureByID(mbox.ID, int64(atoi(idStr)))
}
a.render(w, r, "webmail_signatures.html", M{
"mailbox": mbox, "signatures": signatures, "editing": editing,
"flashes": popFlashes(w, r),
})
}
// webmailSignatureSave creates a new signature, or updates one when id (a hidden
// form field, not a path segment — this is a single form reused for add and edit) is
// set.
func (a *App) webmailSignatureSave(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
return
}
name := strings.TrimSpace(r.FormValue("name"))
contentHTML := r.FormValue("content_html")
if name == "" {
setFlash(w, "error", "Give the signature a name")
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
return
}
id := int64(atoi(r.FormValue("id")))
var err error
if id != 0 {
err = a.DB.UpdateSignature(mbox.ID, id, name, contentHTML)
} else {
_, err = a.DB.CreateSignature(mbox.ID, name, contentHTML)
}
if err != nil {
a.Logger.Error("save signature for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the signature")
} else {
setFlash(w, "success", "Signature saved")
}
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
}
func (a *App) webmailSignatureDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
id := int64(atoi(r.PathValue("id")))
if err := a.DB.DeleteSignature(mbox.ID, id); err != nil {
setFlash(w, "error", "Could not delete the signature")
} else {
setFlash(w, "success", "Signature deleted")
}
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
}
// webmailSignatureSetDefault marks a signature (or, when id=0, clears the flag
// entirely) as the mailbox's default for new messages or for reply/forward, per the
// "which" form field ("new" or "reply").
func (a *App) webmailSignatureSetDefault(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
id := int64(atoi(r.PathValue("id")))
forReply := r.URL.Query().Get("which") == "reply"
if err := a.DB.SetDefaultSignature(mbox.ID, id, forReply); err != nil {
setFlash(w, "error", "Could not update the default signature")
} else {
setFlash(w, "success", "Default signature updated")
}
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
}
+123
View File
@@ -0,0 +1,123 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailSignatureCreateEditDeleteAndDefaults exercises the full signature CRUD
// flow plus setting/clearing the default-for-new and default-for-reply flags.
func TestWebmailSignatureCreateEditDeleteAndDefaults(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sigtest@example.com", domains[0].ID, "sigtest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
save := func(id, name, html string) *httptest.ResponseRecorder {
form := url.Values{"id": {id}, "name": {name}, "content_html": {html}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/signatures/save", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
if rec := save("", "Work", "<p>Jane Doe<br>Acme Inc</p>"); rec.Code != http.StatusFound {
t.Fatalf("create: status=%d body=%s", rec.Code, rec.Body.String())
}
sigs, err := app.DB.ListSignatures(mailboxID)
if err != nil {
t.Fatal(err)
}
if len(sigs) != 1 || sigs[0].Name != "Work" {
t.Fatalf("expected 1 signature named Work, got %+v", sigs)
}
id := sigs[0].ID
// Edit it.
if rec := save(strconv.FormatInt(id, 10), "Work Updated", "<p>Jane Doe, CEO</p>"); rec.Code != http.StatusFound {
t.Fatalf("edit: status=%d body=%s", rec.Code, rec.Body.String())
}
updated, err := app.DB.GetSignatureByID(mailboxID, id)
if err != nil {
t.Fatal(err)
}
if updated == nil || updated.Name != "Work Updated" || !strings.Contains(updated.ContentHTML, "CEO") {
t.Fatalf("expected updated signature, got %+v", updated)
}
// Set as default for both new and reply.
for _, which := range []string{"new", "reply"} {
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/signatures/"+strconv.FormatInt(id, 10)+"/default?which="+which, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("set default %s: status=%d body=%s", which, rec.Code, rec.Body.String())
}
}
got, err := app.DB.GetSignatureByID(mailboxID, id)
if err != nil {
t.Fatal(err)
}
if !got.IsDefaultNew || !got.IsDefaultReply {
t.Fatalf("expected both defaults set, got %+v", got)
}
def, err := app.DB.GetDefaultSignature(mailboxID, false)
if err != nil || def == nil || def.ID != id {
t.Fatalf("GetDefaultSignature(new) = %+v, err=%v", def, err)
}
// Delete it — defaults should just disappear along with the row.
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/signatures/"+strconv.FormatInt(id, 10)+"/delete", nil)
delReq.AddCookie(cookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusFound {
t.Fatalf("delete: status=%d body=%s", delRec.Code, delRec.Body.String())
}
remaining, err := app.DB.ListSignatures(mailboxID)
if err != nil {
t.Fatal(err)
}
if len(remaining) != 0 {
t.Fatalf("expected no signatures left, got %+v", remaining)
}
}
// TestWebmailSignatureOnlyOneDefaultPerMailbox confirms setting a second signature as
// the default-for-new clears the flag from whichever one had it before.
func TestWebmailSignatureOnlyOneDefaultPerMailbox(t *testing.T) {
app := newTestApp(t)
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sigdefault@example.com", domains[0].ID, "sigdefault-password-1!")
id1, err := app.DB.CreateSignature(mailboxID, "One", "<p>1</p>")
if err != nil {
t.Fatal(err)
}
id2, err := app.DB.CreateSignature(mailboxID, "Two", "<p>2</p>")
if err != nil {
t.Fatal(err)
}
if err := app.DB.SetDefaultSignature(mailboxID, id1, false); err != nil {
t.Fatal(err)
}
if err := app.DB.SetDefaultSignature(mailboxID, id2, false); err != nil {
t.Fatal(err)
}
s1, _ := app.DB.GetSignatureByID(mailboxID, id1)
s2, _ := app.DB.GetSignatureByID(mailboxID, id2)
if s1.IsDefaultNew {
t.Error("expected signature 1 to no longer be the default")
}
if !s2.IsDefaultNew {
t.Error("expected signature 2 to now be the default")
}
}
+13
View File
@@ -162,14 +162,27 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/pane", a.webmailMessagePane)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/star", a.webmailToggleStar)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/restore", a.webmailRestoreMessage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/mark-all-read", a.webmailMarkAllRead)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/empty", a.webmailEmptyTrash)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/clean-spam", a.webmailCleanSpam)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/rename", a.webmailRenameFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/delete-permanently", a.webmailDeleteFolderPermanently)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/restore", a.webmailRestoreFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/order", a.webmailSetFolderOrder)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/rules", a.webmailRulesList)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/add", a.webmailAddRule)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/remove", a.webmailRemoveRule)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/signatures", a.webmailSignaturesPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/save", a.webmailSignatureSave)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/delete", a.webmailSignatureDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/default", a.webmailSignatureSetDefault)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/certs", a.webmailCertsPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/generate", a.webmailSMIMEGenerate)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/import", a.webmailSMIMEImport)