updated layout for webmail
This commit is contained in:
@@ -1,18 +1,450 @@
|
||||
package db
|
||||
|
||||
// CreateMailboxFolder records a custom folder's existence even before it holds any
|
||||
// messages — idempotent (a folder a filter rule already delivered into can be
|
||||
// explicitly created too, without erroring on the duplicate).
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// StandardMailboxFolders are the folders every mailbox always has, regardless of
|
||||
// whether they currently hold any mail — the single source of truth shared by the
|
||||
// webmail UI (which always shows them in the sidebar) and the IMAP LIST handler
|
||||
// (which must report them too, or a desktop client never learns Trash/Junk exist
|
||||
// until a message happens to land in one). These 5 are always top-level and never
|
||||
// renamed or reordered relative to each other — only custom folders nested under
|
||||
// INBOX (or, after being deleted, under Trash — see webmailDeleteFolder) form a real
|
||||
// tree; see the parent_id/parent_root doc comment on esrv_mailbox_folders.
|
||||
//
|
||||
// "Junk" (not "Spam") specifically because several desktop IMAP clients look for a
|
||||
// folder literally named "Junk" to auto-recognize it, even though the SPECIAL-USE
|
||||
// \Junk attribute (see specialUseAttrs, internal/imapserver) is supposed to make the
|
||||
// exact name irrelevant — in practice not every client honors SPECIAL-USE reliably.
|
||||
// See migrateSpamRenamedToJunk (schema.go) for existing mailboxes that already had a
|
||||
// "Spam" folder before this rename.
|
||||
var StandardMailboxFolders = []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
|
||||
|
||||
func isStandardFolderName(name string) bool {
|
||||
for _, f := range StandardMailboxFolders {
|
||||
if f == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllFoldersForMailbox is mailboxID's full folder list: the standard folders above,
|
||||
// plus every folder that either holds at least one message (DistinctFoldersForMailbox)
|
||||
// or was explicitly created and is still empty (ListMailboxFolders) — a folder can
|
||||
// exist via either path, sometimes both. This is a flat list (IMAP LIST, the "Move
|
||||
// to…" dropdown, and any other non-tree consumer) — for the sidebar's actual tree
|
||||
// structure, see FolderTree.
|
||||
func (d *DB) AllFoldersForMailbox(mailboxID int64) ([]string, error) {
|
||||
fromMessages, err := d.DistinctFoldersForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
explicit, err := d.ListMailboxFolders(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool, len(StandardMailboxFolders)+len(fromMessages)+len(explicit))
|
||||
out := make([]string, 0, len(StandardMailboxFolders)+len(fromMessages)+len(explicit))
|
||||
add := func(f string) {
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
for _, f := range StandardMailboxFolders {
|
||||
add(f)
|
||||
}
|
||||
for _, f := range fromMessages {
|
||||
add(f)
|
||||
}
|
||||
for _, f := range explicit {
|
||||
add(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unpositionedFolder is the position value a folder row gets when it exists for some
|
||||
// other reason (CreateMailboxFolder, rename, becoming a parent) but has never
|
||||
// actually been dragged — distinct from 0, which is a legitimate "dragged to the very
|
||||
// top" position, so FolderPositions can tell "never ordered" apart from "explicitly
|
||||
// ordered first".
|
||||
const unpositionedFolder = -1
|
||||
|
||||
// FolderPositions returns mailboxID's saved sibling order as {folder name: position}
|
||||
// — for folders that have ever been dragged to a specific spot among their siblings
|
||||
// (see SetFolderOrder). A folder absent from the map has never been reordered.
|
||||
// Positions are only ever compared between actual siblings (see FolderTree); the raw
|
||||
// integer means nothing across different parents.
|
||||
func (d *DB) FolderPositions(mailboxID int64) (map[string]int, error) {
|
||||
rows, err := d.Query(`SELECT name, position FROM esrv_mailbox_folders WHERE mailbox_id = ? AND position >= 0`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var pos int
|
||||
if err := rows.Scan(&name, &pos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = pos
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetFolderOrder persists the drag-and-drop order of one set of siblings in one shot
|
||||
// — order is that parent's full children list top to bottom (never the whole tree;
|
||||
// positions only mean something relative to siblings, see FolderPositions).
|
||||
func (d *DB) SetFolderOrder(mailboxID int64, order []string) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i, name := range order {
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mailbox_id, name) DO UPDATE SET position = excluded.position`, mailboxID, name, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// folderRow is one esrv_mailbox_folders row, as needed to resolve/rebuild the tree.
|
||||
type folderRow struct {
|
||||
id int64
|
||||
name string
|
||||
parentID sql.NullInt64
|
||||
parentRoot string
|
||||
position int
|
||||
}
|
||||
|
||||
func (d *DB) allFolderRows(mailboxID int64) ([]folderRow, error) {
|
||||
rows, err := d.Query(`SELECT id, name, parent_id, parent_root, position FROM esrv_mailbox_folders WHERE mailbox_id = ?`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []folderRow
|
||||
for rows.Next() {
|
||||
var r folderRow
|
||||
if err := rows.Scan(&r.id, &r.name, &r.parentID, &r.parentRoot, &r.position); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FolderParentMap returns, for every custom folder that has a row, its immediate
|
||||
// parent's current display name (one of the 5 standard names, or another custom
|
||||
// folder's current name) — a row with neither parent_id nor parent_root set (legacy
|
||||
// data, or a dangling parent_id somehow) defaults to "INBOX", never crashes.
|
||||
func (d *DB) FolderParentMap(mailboxID int64) (map[string]string, error) {
|
||||
all, err := d.allFolderRows(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[int64]folderRow, len(all))
|
||||
for _, r := range all {
|
||||
byID[r.id] = r
|
||||
}
|
||||
out := make(map[string]string, len(all))
|
||||
for _, r := range all {
|
||||
switch {
|
||||
case r.parentRoot != "":
|
||||
out[r.name] = r.parentRoot
|
||||
case r.parentID.Valid:
|
||||
if p, ok := byID[r.parentID.Int64]; ok {
|
||||
out[r.name] = p.name
|
||||
} else {
|
||||
out[r.name] = "INBOX"
|
||||
}
|
||||
default:
|
||||
out[r.name] = "INBOX"
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FolderRoot resolves which of the 5 standard folders name ultimately lives under —
|
||||
// name itself if it's already standard, otherwise walking up the parent chain. Used
|
||||
// to decide "is this folder (still) under INBOX" (renameable, can hold new
|
||||
// subfolders) vs "under Trash" (a deleted folder's new home — see
|
||||
// webmailDeleteFolder). A cycle or dangling reference (shouldn't happen) falls back
|
||||
// to "INBOX" rather than looping forever.
|
||||
func (d *DB) FolderRoot(mailboxID int64, name string) (string, error) {
|
||||
if isStandardFolderName(name) {
|
||||
return name, nil
|
||||
}
|
||||
parents, err := d.FolderParentMap(mailboxID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
cur := name
|
||||
for {
|
||||
if isStandardFolderName(cur) {
|
||||
return cur, nil
|
||||
}
|
||||
if seen[cur] {
|
||||
return "INBOX", nil
|
||||
}
|
||||
seen[cur] = true
|
||||
next, ok := parents[cur]
|
||||
if !ok {
|
||||
return "INBOX", nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
}
|
||||
|
||||
// FolderSubtreeNames returns startName plus every descendant folder (any depth) —
|
||||
// backs "Empty Trash" (Trash's whole subtree, since deleting a folder re-parents it
|
||||
// there) and "Clean up Junk" (Junk never has children, so this is just ["Junk"]) when
|
||||
// startName is one of the 5 standard folders, and permanently deleting or restoring
|
||||
// one specific custom folder (plus whatever's nested under it) when it isn't.
|
||||
func (d *DB) FolderSubtreeNames(mailboxID int64, startName string) ([]string, error) {
|
||||
all, err := d.allFolderRows(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inSubtree := map[int64]bool{}
|
||||
for _, r := range all {
|
||||
if r.name == startName {
|
||||
inSubtree[r.id] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
out := []string{startName}
|
||||
// Direct children referencing startName as their standard-root parent — only
|
||||
// matches when startName is itself one of the 5 standard folders.
|
||||
for _, r := range all {
|
||||
if r.parentRoot == startName {
|
||||
out = append(out, r.name)
|
||||
inSubtree[r.id] = true
|
||||
}
|
||||
}
|
||||
// Everything deeper nests via parent_id chains — repeat until a pass finds
|
||||
// nothing new, which correctly handles any depth without a recursive query.
|
||||
for {
|
||||
added := false
|
||||
for _, r := range all {
|
||||
if inSubtree[r.id] {
|
||||
continue
|
||||
}
|
||||
if r.parentID.Valid && inSubtree[r.parentID.Int64] {
|
||||
out = append(out, r.name)
|
||||
inSubtree[r.id] = true
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFolderRows removes esrv_mailbox_folders records for the given names — used
|
||||
// after permanently deleting a folder's messages (Empty Trash, Clean up Junk, or
|
||||
// permanently deleting one Trash-nested folder), so a permanently-deleted folder
|
||||
// doesn't linger as an empty shell in the sidebar the way a merely-emptied one does.
|
||||
func (d *DB) DeleteFolderRows(mailboxID int64, names []string) error {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, name := range names {
|
||||
if _, err := tx.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// FolderNode is one node of the sidebar's actual folder tree — see FolderTree.
|
||||
type FolderNode struct {
|
||||
Name string
|
||||
Children []*FolderNode
|
||||
Renameable bool // a custom folder still under INBOX (not INBOX itself, not under Trash)
|
||||
CanAddKid bool // INBOX itself, or a custom folder still under INBOX
|
||||
UnderTrash bool // a custom folder that's been deleted into Trash — can be permanently deleted or restored
|
||||
}
|
||||
|
||||
// FolderTree builds the mailbox's sidebar tree: the 5 standard folders as fixed-order
|
||||
// roots (never reordered relative to each other — see StandardMailboxFolders), each
|
||||
// with its custom-folder descendants nested underneath, siblings ordered by any saved
|
||||
// drag position (FolderPositions), unpositioned ones keeping insertion order.
|
||||
func (d *DB) FolderTree(mailboxID int64) ([]*FolderNode, error) {
|
||||
flat, err := d.AllFoldersForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parents, err := d.FolderParentMap(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
positions, err := d.FolderPositions(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
childrenOf := map[string][]string{}
|
||||
for _, f := range flat {
|
||||
if isStandardFolderName(f) {
|
||||
continue
|
||||
}
|
||||
p := parents[f]
|
||||
childrenOf[p] = append(childrenOf[p], f)
|
||||
}
|
||||
for parent := range childrenOf {
|
||||
kids := childrenOf[parent]
|
||||
sort.SliceStable(kids, func(i, j int) bool {
|
||||
pi, oki := positions[kids[i]]
|
||||
pj, okj := positions[kids[j]]
|
||||
if oki && okj {
|
||||
return pi < pj
|
||||
}
|
||||
return oki && !okj
|
||||
})
|
||||
childrenOf[parent] = kids
|
||||
}
|
||||
var build func(name string) *FolderNode
|
||||
build = func(name string) *FolderNode {
|
||||
root, _ := d.FolderRoot(mailboxID, name)
|
||||
n := &FolderNode{
|
||||
Name: name,
|
||||
Renameable: !isStandardFolderName(name) && root == "INBOX",
|
||||
CanAddKid: root == "INBOX",
|
||||
UnderTrash: !isStandardFolderName(name) && root == "Trash",
|
||||
}
|
||||
for _, childName := range childrenOf[name] {
|
||||
n.Children = append(n.Children, build(childName))
|
||||
}
|
||||
return n
|
||||
}
|
||||
roots := make([]*FolderNode, 0, len(StandardMailboxFolders))
|
||||
for _, r := range StandardMailboxFolders {
|
||||
roots = append(roots, build(r))
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// CreateMailboxFolder ensures a folder row exists, purely to hold position/rename
|
||||
// metadata — idempotent (INSERT OR IGNORE), so calling it on an already-existing
|
||||
// folder is a safe no-op that never clobbers that folder's real parent. Only used
|
||||
// when the caller doesn't care about (or already knows) the parent — see
|
||||
// CreateMailboxFolderUnder for actually creating a new folder under a chosen parent.
|
||||
func (d *DB) CreateMailboxFolder(mailboxID int64, name string) error {
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name) VALUES (?, ?)`, mailboxID, name)
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name, position) VALUES (?, ?, ?)`, mailboxID, name, unpositionedFolder)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMailboxFolder removes a custom folder's record. Callers are responsible for
|
||||
// relocating any messages still in it first (see MoveAllMessagesInFolder) — this
|
||||
// alone doesn't touch esrv_mailbox_messages.
|
||||
func (d *DB) DeleteMailboxFolder(mailboxID int64, name string) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name)
|
||||
// CreateMailboxFolderUnder creates a brand-new custom folder as a child of parent —
|
||||
// parent must already be valid (a standard name, or an existing custom folder with
|
||||
// its own row) by the time this is called; see webmailAddFolder for the validation
|
||||
// this relies on.
|
||||
func (d *DB) CreateMailboxFolderUnder(mailboxID int64, name, parent string) error {
|
||||
if isStandardFolderName(parent) {
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, position) VALUES (?, ?, NULL, ?, ?)`,
|
||||
mailboxID, name, parent, unpositionedFolder)
|
||||
return err
|
||||
}
|
||||
var parentID int64
|
||||
if err := d.QueryRow(`SELECT id FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, parent).Scan(&parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, position) VALUES (?, ?, ?, '', ?)`,
|
||||
mailboxID, name, parentID, unpositionedFolder)
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveFolderToTrash re-parents a folder (and, since its descendants reference it by
|
||||
// id rather than a materialized path, its whole subtree along with it) under Trash —
|
||||
// this IS "delete a folder": see webmailDeleteFolder for why messages are never
|
||||
// separately relocated. Captures the folder's current parent into
|
||||
// restore_parent_id/restore_parent_root first, so RestoreFolder can put it back where
|
||||
// it came from later.
|
||||
func (d *DB) MoveFolderToTrash(mailboxID int64, name string) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var id int64
|
||||
var parentID sql.NullInt64
|
||||
var parentRoot string
|
||||
err = tx.QueryRow(`SELECT id, parent_id, parent_root FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name).
|
||||
Scan(&id, &parentID, &parentRoot)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// No row yet (a message-derived-only folder, never explicitly parented) — its
|
||||
// restore target defaults to INBOX, the same default FolderParentMap already
|
||||
// uses for a row-less folder.
|
||||
if _, err := tx.Exec(`INSERT INTO esrv_mailbox_folders (mailbox_id, name, parent_id, parent_root, restore_parent_id, restore_parent_root, position) VALUES (?, ?, NULL, 'Trash', NULL, 'INBOX', ?)`,
|
||||
mailboxID, name, unpositionedFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
restoreRoot := parentRoot
|
||||
if !parentID.Valid && parentRoot == "" {
|
||||
restoreRoot = "INBOX"
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE esrv_mailbox_folders SET restore_parent_id = ?, restore_parent_root = ?, parent_id = NULL, parent_root = 'Trash' WHERE id = ?`,
|
||||
parentID, restoreRoot, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RestoreFolder re-parents a folder back to where it was the moment it was deleted
|
||||
// (see MoveFolderToTrash's restore_parent_id/restore_parent_root capture), falling
|
||||
// back to INBOX if that's no longer meaningful — its former parent has itself since
|
||||
// been deleted into Trash too, so restoring underneath it would leave this folder
|
||||
// looking un-restored (still inside Trash's subtree).
|
||||
func (d *DB) RestoreFolder(mailboxID int64, name string) error {
|
||||
var restoreParentID sql.NullInt64
|
||||
var restoreParentRoot string
|
||||
if err := d.QueryRow(`SELECT restore_parent_id, restore_parent_root FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name).
|
||||
Scan(&restoreParentID, &restoreParentRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parentID := restoreParentID
|
||||
parentRoot := restoreParentRoot
|
||||
if parentID.Valid {
|
||||
valid := false
|
||||
var parentName string
|
||||
if err := d.QueryRow(`SELECT name FROM esrv_mailbox_folders WHERE id = ?`, parentID.Int64).Scan(&parentName); err == nil {
|
||||
if root, err := d.FolderRoot(mailboxID, parentName); err == nil && root != "Trash" {
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
parentRoot = ""
|
||||
} else {
|
||||
parentID = sql.NullInt64{}
|
||||
parentRoot = "INBOX"
|
||||
}
|
||||
} else if parentRoot == "" {
|
||||
parentRoot = "INBOX"
|
||||
}
|
||||
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_folders SET parent_id = ?, parent_root = ?, restore_parent_id = NULL, restore_parent_root = '' WHERE mailbox_id = ? AND name = ?`,
|
||||
parentID, parentRoot, mailboxID, name)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -36,10 +468,20 @@ func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MoveAllMessagesInFolder reassigns every message in one folder to another — used
|
||||
// when deleting a custom folder, so its messages land in INBOX instead of becoming
|
||||
// orphaned in a folder nothing lists anymore.
|
||||
func (d *DB) MoveAllMessagesInFolder(mailboxID int64, from, to string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, to, mailboxID, from)
|
||||
// RenameMailboxFolder changes a folder's display name only — a single-row UPDATE is
|
||||
// enough (no cascade needed): children reference their parent by row id, not by name,
|
||||
// so they stay correctly nested with zero further writes.
|
||||
func (d *DB) RenameMailboxFolder(mailboxID int64, oldName, newName string) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`UPDATE esrv_mailbox_folders SET name = ? WHERE mailbox_id = ? AND name = ?`, newName, mailboxID, oldName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, newName, mailboxID, oldName); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCreateMailboxFolderDoesNotDisturbOrder is a regression test for a real bug
|
||||
// caught via live testing: CreateMailboxFolder's INSERT OR IGNORE used to leave a
|
||||
// fresh row's position at the column's SQL default (0), which is indistinguishable
|
||||
// from "explicitly dragged to the very top" — so creating a brand-new custom folder
|
||||
// made it jump above INBOX in the sidebar the instant it was created, before the user
|
||||
// ever touched drag-and-drop. Fixed by writing an explicit sentinel (unpositionedFolder,
|
||||
// -1) for a row that exists but was never dragged.
|
||||
func TestCreateMailboxFolderDoesNotDisturbOrder(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolder(mailboxID, "Receipts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folders, err := d.AllFoldersForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(folders) == 0 || folders[0] != "INBOX" {
|
||||
t.Fatalf("expected INBOX first, got %v", folders)
|
||||
}
|
||||
if folders[len(folders)-1] != "Receipts" {
|
||||
t.Fatalf("expected Receipts last (never dragged), got %v", folders)
|
||||
}
|
||||
}
|
||||
|
||||
// treeNames flattens a []*FolderNode into a depth-first name list for easy assertion.
|
||||
func treeNames(nodes []*FolderNode) []string {
|
||||
var out []string
|
||||
var walk func(n *FolderNode)
|
||||
walk = func(n *FolderNode) {
|
||||
out = append(out, n.Name)
|
||||
for _, c := range n.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, n := range nodes {
|
||||
walk(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAllFoldersForMailboxRootOrderIsFixed confirms the 5 standard folders are always
|
||||
// returned in their fixed StandardMailboxFolders order — root folders are static and
|
||||
// were deliberately made non-reorderable, so even a stray saved position for one
|
||||
// (however it got there) must never change their relative order.
|
||||
func TestAllFoldersForMailboxRootOrderIsFixed(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.SetFolderOrder(mailboxID, []string{"Trash", "Junk", "INBOX", "Drafts", "Sent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folders, err := d.AllFoldersForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
|
||||
if len(folders) != len(want) {
|
||||
t.Fatalf("folders = %v, want %v", folders, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if folders[i] != name {
|
||||
t.Fatalf("folders = %v, want %v", folders, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFolderTreeNestsUnderParentAndOrdersSiblings confirms CreateMailboxFolderUnder
|
||||
// actually nests a folder under its chosen parent in FolderTree, and that a saved
|
||||
// sibling order (SetFolderOrder, scoped to just that parent's children) is respected
|
||||
// — a folder created afterwards (never dragged) appends after the ordered siblings.
|
||||
func TestFolderTreeNestsUnderParentAndOrdersSiblings(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientB", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetFolderOrder(mailboxID, []string{"ClientB", "ClientA"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tree, err := d.FolderTree(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := treeNames(tree)
|
||||
want := []string{"INBOX", "Projects", "ClientB", "ClientA", "Junk", "Sent", "Drafts", "Trash"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("tree = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Fatalf("tree = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientC", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err = d.FolderTree(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = treeNames(tree)
|
||||
if got[len(got)-5] != "ClientC" { // last of Projects' 3 children, before Junk/Sent/Drafts/Trash
|
||||
t.Fatalf("expected ClientC to append after the ordered siblings, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenameMailboxFolderKeepsChildrenNested confirms renaming a folder that has
|
||||
// children leaves them correctly nested (they reference their parent by row id, not
|
||||
// by name, so a plain single-row UPDATE never orphans them).
|
||||
func TestRenameMailboxFolderKeepsChildrenNested(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RenameMailboxFolder(mailboxID, "Projects", "Work"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root, err := d.FolderRoot(mailboxID, "ClientA")
|
||||
if err != nil || root != "INBOX" {
|
||||
t.Fatalf("expected ClientA to still resolve under INBOX after its parent was renamed, root=%q err=%v", root, err)
|
||||
}
|
||||
tree, err := d.FolderTree(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := treeNames(tree)
|
||||
want := []string{"INBOX", "Work", "ClientA", "Junk", "Sent", "Drafts", "Trash"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("tree = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Fatalf("tree = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoveFolderToTrashKeepsSubtreeIntact confirms deleting a folder that has its own
|
||||
// children re-parents the whole subtree under Trash in one move — the children,
|
||||
// referencing their parent by id, come along automatically with zero further writes.
|
||||
func TestMoveFolderToTrashKeepsSubtreeIntact(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.MoveFolderToTrash(mailboxID, "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"Projects", "ClientA"} {
|
||||
root, err := d.FolderRoot(mailboxID, name)
|
||||
if err != nil || root != "Trash" {
|
||||
t.Fatalf("expected %s to resolve under Trash, got root=%q (err=%v)", name, root, err)
|
||||
}
|
||||
}
|
||||
names, err := d.FolderSubtreeNames(mailboxID, "Trash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{"Trash": true, "Projects": true, "ClientA": true}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("FolderSubtreeNames(Trash) = %v, want exactly %v", names, want)
|
||||
}
|
||||
for _, n := range names {
|
||||
if !want[n] {
|
||||
t.Fatalf("FolderSubtreeNames(Trash) = %v, want exactly %v", names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFolderReturnsToOriginalParent confirms deleting a nested folder and then
|
||||
// restoring it puts it back exactly where it was (not just dumped at INBOX's top
|
||||
// level) — its sibling, untouched throughout, proves the parent (Projects) itself
|
||||
// never moved.
|
||||
func TestRestoreFolderReturnsToOriginalParent(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.MoveFolderToTrash(mailboxID, "ClientA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root, err := d.FolderRoot(mailboxID, "ClientA"); err != nil || root != "Trash" {
|
||||
t.Fatalf("expected ClientA under Trash after delete, root=%q err=%v", root, err)
|
||||
}
|
||||
|
||||
if err := d.RestoreFolder(mailboxID, "ClientA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err := d.FolderTree(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := treeNames(tree)
|
||||
want := []string{"INBOX", "Projects", "ClientA", "Junk", "Sent", "Drafts", "Trash"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("tree after restore = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Fatalf("tree after restore = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFolderFallsBackToInboxWhenOriginalParentStillTrashed confirms restoring
|
||||
// a folder whose former parent is itself still in Trash doesn't leave it looking
|
||||
// un-restored (nested inside another trashed folder) — it falls back to INBOX
|
||||
// instead.
|
||||
func TestRestoreFolderFallsBackToInboxWhenOriginalParentStillTrashed(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.MoveFolderToTrash(mailboxID, "ClientA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Now trash Projects too — ClientA's restore target (Projects) is itself trashed.
|
||||
if err := d.MoveFolderToTrash(mailboxID, "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RestoreFolder(mailboxID, "ClientA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := d.FolderRoot(mailboxID, "ClientA")
|
||||
if err != nil || root != "INBOX" {
|
||||
t.Fatalf("expected ClientA to fall back under INBOX, got root=%q (err=%v)", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFolderRowsRemovesRecords confirms DeleteFolderRows actually removes the
|
||||
// esrv_mailbox_folders rows (not just messages) — used by permanent deletes so a
|
||||
// permanently-removed folder doesn't linger as an empty shell in FolderTree.
|
||||
func TestDeleteFolderRowsRemovesRecords(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "Projects", "INBOX"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.CreateMailboxFolderUnder(mailboxID, "ClientA", "Projects"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.DeleteFolderRows(mailboxID, []string{"Projects", "ClientA"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err := d.FolderTree(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := treeNames(tree)
|
||||
want := []string{"INBOX", "Junk", "Sent", "Drafts", "Trash"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("tree after DeleteFolderRows = %v, want %v (Projects/ClientA gone)", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Fatalf("tree after DeleteFolderRows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,33 @@ func (d *DB) MoveMessage(mailboxID, uid int64, newFolder string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveMessageToTrash moves a message to Trash, first capturing its current folder
|
||||
// into restore_folder so RestoreMessage can put it back later (mirrors
|
||||
// db.MoveFolderToTrash's identical capture for folders).
|
||||
func (d *DB) MoveMessageToTrash(mailboxID, uid int64) error {
|
||||
var folder string
|
||||
if err := d.QueryRow(`SELECT folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID).Scan(&folder); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET restore_folder = ?, folder = 'Trash' WHERE id = ? AND mailbox_id = ?`, folder, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreMessage moves a message back to the folder it was in immediately before
|
||||
// being moved to Trash (see MoveMessageToTrash), or INBOX if that's unknown (e.g. a
|
||||
// message trashed before this feature existed, or moved to Trash some other way).
|
||||
func (d *DB) RestoreMessage(mailboxID, uid int64) error {
|
||||
var restoreFolder string
|
||||
if err := d.QueryRow(`SELECT restore_folder FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID).Scan(&restoreFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
if restoreFolder == "" {
|
||||
restoreFolder = "INBOX"
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ?, restore_folder = '' WHERE id = ? AND mailbox_id = ?`, restoreFolder, uid, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by
|
||||
// mailbox removal to delete each one's on-disk ciphertext via mailstore before the
|
||||
// mailbox row itself is removed.
|
||||
@@ -155,12 +182,15 @@ func sortColumnAndDir(sortBy, sortDir string) string {
|
||||
return col + " " + dir + ", id " + dir
|
||||
}
|
||||
|
||||
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) {
|
||||
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly, starredOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) {
|
||||
query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
|
||||
args := []any{mailboxID, folder}
|
||||
if unreadOnly {
|
||||
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
|
||||
}
|
||||
if starredOnly {
|
||||
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
|
||||
}
|
||||
query += ` ORDER BY ` + sortColumnAndDir(sortBy, sortDir) + ` LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
rows, err := d.Query(query, args...)
|
||||
@@ -171,17 +201,59 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly
|
||||
}
|
||||
|
||||
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
|
||||
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly bool) (int, error) {
|
||||
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly, starredOnly bool) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
|
||||
args := []any{mailboxID, folder}
|
||||
if unreadOnly {
|
||||
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
|
||||
}
|
||||
if starredOnly {
|
||||
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
|
||||
}
|
||||
var n int
|
||||
err := d.QueryRow(query, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// MarkAllReadInFolder adds \Seen to every currently-unread message in one folder —
|
||||
// the folder-context-menu "Mark all as read" action's bulk equivalent of opening each
|
||||
// message individually. Messages that already have other flags (e.g. \Flagged) keep
|
||||
// them; only \Seen is added.
|
||||
func (d *DB) MarkAllReadInFolder(mailboxID int64, folder string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET flags = TRIM(flags || ' \Seen')
|
||||
WHERE mailbox_id = ? AND folder = ? AND flags NOT LIKE '%\Seen%' ESCAPE '\'`, mailboxID, folder)
|
||||
return err
|
||||
}
|
||||
|
||||
// ToggleMessageStarred flips \Flagged (IMAP's standard "starred/important" flag) on
|
||||
// one message — reusing the existing flags column instead of a new schema column
|
||||
// keeps this consistent with a desktop IMAP client's own star/flag button acting on
|
||||
// the same message.
|
||||
func (d *DB) ToggleMessageStarred(mailboxID, uid int64) error {
|
||||
msg, err := d.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil || msg == nil {
|
||||
return err
|
||||
}
|
||||
var newFlags string
|
||||
if isStarred(msg.Flags) {
|
||||
newFlags = strings.TrimSpace(strings.ReplaceAll(msg.Flags, `\Flagged`, ""))
|
||||
} else {
|
||||
newFlags = strings.TrimSpace(msg.Flags + ` \Flagged`)
|
||||
}
|
||||
return d.SetMessageFlags(mailboxID, uid, newFlags)
|
||||
}
|
||||
|
||||
// isStarred reports whether flags (the same space-separated IMAP flags string
|
||||
// isUnread checks in internal/webui) includes \Flagged.
|
||||
func isStarred(flags string) bool {
|
||||
for _, f := range strings.Fields(flags) {
|
||||
if f == `\Flagged` {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters
|
||||
// (%, _, and the escape character itself) so a search for e.g. "50% off" or a
|
||||
// filename with an underscore doesn't get interpreted as a wildcard.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const signatureColumns = `id, mailbox_id, name, content_html, is_default_new, is_default_reply, created_at`
|
||||
|
||||
func scanSignature(scan func(dest ...any) error) (MailboxSignature, error) {
|
||||
var s MailboxSignature
|
||||
var createdAt string
|
||||
err := scan(&s.ID, &s.MailboxID, &s.Name, &s.ContentHTML, &s.IsDefaultNew, &s.IsDefaultReply, &createdAt)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.CreatedAt, _ = parseTime(createdAt)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListSignatures returns a mailbox's saved signatures, most recently created first.
|
||||
func (d *DB) ListSignatures(mailboxID int64) ([]MailboxSignature, error) {
|
||||
rows, err := d.Query(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE mailbox_id = ? ORDER BY id DESC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxSignature
|
||||
for rows.Next() {
|
||||
s, err := scanSignature(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSignatureByID scopes the lookup to mailboxID so one mailbox can never read or
|
||||
// (via UpdateSignature/DeleteSignature, which reuse this same WHERE clause) modify
|
||||
// another's signature by guessing an id.
|
||||
func (d *DB) GetSignatureByID(mailboxID, id int64) (*MailboxSignature, error) {
|
||||
row := d.QueryRow(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
s, err := scanSignature(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GetDefaultSignature returns the mailbox's default-for-new (forReply=false) or
|
||||
// default-for-reply/forward (forReply=true) signature, or nil if none is set.
|
||||
func (d *DB) GetDefaultSignature(mailboxID int64, forReply bool) (*MailboxSignature, error) {
|
||||
col := "is_default_new"
|
||||
if forReply {
|
||||
col = "is_default_reply"
|
||||
}
|
||||
row := d.QueryRow(`SELECT `+signatureColumns+` FROM esrv_mailbox_signatures WHERE mailbox_id = ? AND `+col+` = 1 LIMIT 1`, mailboxID)
|
||||
s, err := scanSignature(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateSignature(mailboxID int64, name, contentHTML string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_signatures (mailbox_id, name, content_html) VALUES (?, ?, ?)`, mailboxID, name, contentHTML)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateSignature(mailboxID, id int64, name, contentHTML string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_signatures SET name = ?, content_html = ? WHERE id = ? AND mailbox_id = ?`, name, contentHTML, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSignature(mailboxID, id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDefaultSignature makes id the mailbox's default-for-new (forReply=false) or
|
||||
// default-for-reply/forward (forReply=true) signature, clearing the flag from every
|
||||
// other row first so at most one ever holds it — id=0 just clears the flag from
|
||||
// everything, i.e. "no default".
|
||||
func (d *DB) SetDefaultSignature(mailboxID, id int64, forReply bool) error {
|
||||
col := "is_default_new"
|
||||
if forReply {
|
||||
col = "is_default_reply"
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 0 WHERE mailbox_id = ?`, mailboxID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 1 WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -152,6 +152,19 @@ type MailboxMessage struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxSignature is one of a mailbox's saved email signatures — HTML content, shown
|
||||
// as an option in compose and optionally auto-inserted for a new message or a
|
||||
// reply/forward (IsDefaultNew/IsDefaultReply).
|
||||
type MailboxSignature struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Name string
|
||||
ContentHTML string
|
||||
IsDefaultNew bool
|
||||
IsDefaultReply bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key
|
||||
// pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is
|
||||
// sign-only in this codebase, so the key never protects anything beyond what the
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMoveMessageToTrashAndRestore confirms a message trashed via MoveMessageToTrash
|
||||
// remembers its prior folder and RestoreMessage puts it back there.
|
||||
func TestMoveMessageToTrashAndRestore(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
uid, err := d.InsertMessage(mailboxID, "Newsletters", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.MoveMessageToTrash(mailboxID, uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, err := d.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil || msg.Folder != "Trash" {
|
||||
t.Fatalf("expected message in Trash, got folder=%q (err=%v)", msg.Folder, err)
|
||||
}
|
||||
|
||||
if err := d.RestoreMessage(mailboxID, uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, err = d.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil || msg.Folder != "Newsletters" {
|
||||
t.Fatalf("expected message restored to Newsletters, got folder=%q (err=%v)", msg.Folder, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreMessageFallsBackToInboxWhenUnknown confirms a message with no recorded
|
||||
// restore_folder (e.g. trashed before this feature existed) restores to INBOX rather
|
||||
// than erroring or restoring to an empty folder name.
|
||||
func TestRestoreMessageFallsBackToInboxWhenUnknown(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
uid, err := d.InsertMessage(mailboxID, "Trash", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RestoreMessage(mailboxID, uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, err := d.GetMessageByUID(mailboxID, uid)
|
||||
if err != nil || msg.Folder != "INBOX" {
|
||||
t.Fatalf("expected fallback to INBOX, got folder=%q (err=%v)", msg.Folder, err)
|
||||
}
|
||||
}
|
||||
+84
-3
@@ -316,6 +316,11 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
|
||||
-- other cached_* columns) so the folder list can show a preview snippet without
|
||||
-- decrypting the full message just to render the list.
|
||||
cached_preview TEXT NOT NULL DEFAULT '',
|
||||
-- Snapshot of folder taken the moment a message is moved to Trash (see
|
||||
-- db.MoveMessageToTrash), so "Restore" (db.RestoreMessage) can put it back where
|
||||
-- it came from instead of just dumping it in INBOX. Empty outside of that window
|
||||
-- (cleared again once restored).
|
||||
restore_folder TEXT NOT NULL DEFAULT '',
|
||||
storage_path TEXT NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -328,13 +333,47 @@ CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages(
|
||||
|
||||
-- Explicit record of a mailbox's custom folders, so a freshly created (still empty)
|
||||
-- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove
|
||||
-- a folder exists once it holds at least one message. Standard folders (INBOX, Spam,
|
||||
-- Sent, Drafts, Trash) are never stored here; they're always shown by the webui
|
||||
-- regardless of this table.
|
||||
-- a folder exists once it holds at least one message. Standard folders (INBOX, Junk,
|
||||
-- Sent, Drafts, Trash) don't need a row to be listed — they're always shown by the
|
||||
-- webui regardless — but DO get one the first time they're renamed, dragged to a new
|
||||
-- position, or made a parent, purely to hold that metadata (see CreateMailboxFolder,
|
||||
-- SetFolderOrder).
|
||||
--
|
||||
-- Custom folders form a real tree, always rooted at one of the 5 standard folders
|
||||
-- (webui only ever offers "New folder" under INBOX, and "delete a folder" re-parents
|
||||
-- it under Trash — see webmailDeleteFolder). Exactly one of parent_id/parent_root is
|
||||
-- ever set per row: parent_id (an id, not a name) when the parent is another custom
|
||||
-- folder — immune to that parent later being renamed, unlike a name-based reference —
|
||||
-- parent_root (one of the 5 fixed, never-renamed standard names) when the parent is a
|
||||
-- standard folder that may not have its own row. A row with BOTH unset (parent_id
|
||||
-- NULL, parent_root '') is legacy data predating this column and is treated as
|
||||
-- "under INBOX" (see db.FolderParentMap) — every custom folder created before this
|
||||
-- feature was flat/top-level anyway, so that default is exactly correct, no backfill
|
||||
-- migration needed.
|
||||
--
|
||||
-- esrv_mailbox_messages.folder itself is untouched by any of this — messages are
|
||||
-- still tagged with a flat folder name exactly as before; the parent/child
|
||||
-- relationship here is purely an organizational layer for the sidebar.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES esrv_mailbox_folders(id),
|
||||
parent_root TEXT NOT NULL DEFAULT '',
|
||||
-- Sidebar sort position among this folder's siblings (lower first), set only once
|
||||
-- the user drags to reorder. The app always writes an explicit value: -1
|
||||
-- (db.unpositionedFolder) for a row that exists for some other reason (rename, a
|
||||
-- folder just created, becoming a parent) but was never dragged, so it doesn't
|
||||
-- look like a real "dragged to the very top" (0) — FolderPositions filters on
|
||||
-- position >= 0 for exactly this reason. Column default of 0 is inert (every write
|
||||
-- path is explicit); kept only as a harmless fallback.
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
-- Snapshot of parent_id/parent_root taken the moment a folder is deleted (moved
|
||||
-- under Trash — see db.MoveFolderToTrash), so "Restore" (db.RestoreFolder) can put
|
||||
-- it back where it came from instead of just dumping it at INBOX's top level.
|
||||
-- Empty/NULL outside of that window (cleared again once restored).
|
||||
restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id),
|
||||
restore_parent_root TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, name)
|
||||
);
|
||||
@@ -391,6 +430,25 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- A mailbox's saved email signatures — HTML content (compose is HTML/Quill-based
|
||||
-- already; a plain-text signature is just HTML with no formatting, so one content
|
||||
-- column covers both instead of storing two parallel copies). A mailbox may hold
|
||||
-- several, e.g. one formal and one casual; is_default_new/is_default_reply pick which
|
||||
-- one (if any) compose pre-fills for a brand-new message vs a reply/forward — at most
|
||||
-- one row per mailbox should have each flag set, enforced in the db package (Go), not
|
||||
-- a SQL constraint, since "make this one the default" is naturally an
|
||||
-- update-this-then-clear-the-others operation either way.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL,
|
||||
content_html TEXT NOT NULL DEFAULT '',
|
||||
is_default_new INTEGER NOT NULL DEFAULT 0,
|
||||
is_default_reply INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mailbox_signatures_mailbox ON esrv_mailbox_signatures(mailbox_id);
|
||||
|
||||
-- Other people's PGP public keys a mailbox owner has collected, added by hand —
|
||||
-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a
|
||||
-- recipient in compose.
|
||||
@@ -441,6 +499,12 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN group_messages INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_preview TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN position INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_root TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_root TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
// The three old columns above were NOT NULL with no default, so simply adding
|
||||
// key_pem left them behind still blocking every new insert (which only ever sets
|
||||
@@ -458,6 +522,23 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
// defaults to 0 for every pre-existing row above, which would otherwise let that
|
||||
// account skip its username change entirely once it re-hits /first-login next.
|
||||
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
|
||||
migrateSpamRenamedToJunk(db)
|
||||
}
|
||||
|
||||
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
|
||||
// that already had messages/records under the old name — "Junk" is what most desktop
|
||||
// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always
|
||||
// safe to re-run: once nothing's left named "Spam" every UPDATE here matches zero
|
||||
// rows.
|
||||
// ponytail: doesn't handle the (very unlikely) case where a mailbox already has an
|
||||
// unrelated custom folder literally named "Junk" before this rename — that one row's
|
||||
// UPDATE would fail on the UNIQUE(mailbox_id, name) constraint and get silently
|
||||
// skipped, same as every other best-effort statement in this function. Rename that
|
||||
// mailbox's pre-existing "Junk" folder first if this ever comes up in practice.
|
||||
func migrateSpamRenamedToJunk(db *sql.DB) {
|
||||
db.Exec(`UPDATE esrv_mailbox_messages SET folder = 'Junk' WHERE folder = 'Spam'`)
|
||||
db.Exec(`UPDATE esrv_mailbox_folders SET name = 'Junk' WHERE name = 'Spam'`)
|
||||
db.Exec(`UPDATE esrv_mailbox_folders SET parent_root = 'Junk' WHERE parent_root = 'Spam'`)
|
||||
}
|
||||
|
||||
// DB wraps *sql.DB with the query helpers below.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestAdminRulesAddMultiConditionAndRenders(t *testing.T) {
|
||||
if !strings.Contains(body, "to contains "sales"") || !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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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 "{{.}}"? 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 "{{.Name}}"? 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>
|
||||
|
||||
@@ -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 "{{.Name}}"?');">
|
||||
<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}}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+448
-65
@@ -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)
|
||||
}
|
||||
explicit, err := a.DB.ListMailboxFolders(mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
// 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"
|
||||
}
|
||||
return mergeFolders(append(fromMessages, explicit...)), nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
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")
|
||||
if err := a.DB.MoveFolderToTrash(mbox.ID, name); err != nil {
|
||||
setFlash(w, "error", "Error deleting folder")
|
||||
} else {
|
||||
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
|
||||
}
|
||||
if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
|
||||
setFlash(w, "error", "Error removing folder")
|
||||
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", "Folder "+name+" removed — any mail in it moved to INBOX")
|
||||
setFlash(w, "success", "All messages in "+folder+" marked as read")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user