2026-08-14 13:04:55 +01:00
|
|
|
package webui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-15 18:38:11 +01:00
|
|
|
"archive/zip"
|
|
|
|
|
"encoding/base64"
|
2026-08-15 12:35:44 +01:00
|
|
|
"fmt"
|
2026-08-14 13:04:55 +01:00
|
|
|
"html/template"
|
|
|
|
|
"net/http"
|
2026-08-15 18:38:11 +01:00
|
|
|
"net/mail"
|
2026-08-15 12:35:44 +01:00
|
|
|
"net/url"
|
2026-08-15 18:38:11 +01:00
|
|
|
"path/filepath"
|
2026-08-14 13:04:55 +01:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
|
|
|
"mailgoserver/internal/db"
|
|
|
|
|
"mailgoserver/internal/mailview"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const webmailPageSize = 25
|
|
|
|
|
|
|
|
|
|
// htmlBodyPolicy sanitizes an HTML email body before it's ever embedded into a page
|
|
|
|
|
// as template.HTML — an email body is attacker-controlled content (anyone can send a
|
|
|
|
|
// mailbox a message), so rendering it unsanitized would be a straightforward stored
|
|
|
|
|
// XSS vector. UGCPolicy allows the common formatting tags/attributes a real email
|
|
|
|
|
// body uses while stripping <script>, event handlers, javascript: URLs, etc.
|
|
|
|
|
// AllowDataURIImages additionally permits img[src] as a base64 data: URI, restricted
|
|
|
|
|
// to actual decodable image/{gif,jpeg,png,webp} content (not a blanket data: URI
|
|
|
|
|
// allowance) — needed so a screenshot pasted into the Quill compose editor (which
|
|
|
|
|
// embeds pastes as inline base64 images) still renders once sanitized, both in the
|
|
|
|
|
// sender's own Sent view and the recipient's inbox.
|
|
|
|
|
var htmlBodyPolicy = func() *bluemonday.Policy {
|
|
|
|
|
p := bluemonday.UGCPolicy()
|
|
|
|
|
p.AllowDataURIImages()
|
|
|
|
|
return p
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// plainTextPolicy strips all HTML tags, leaving only text content — used to derive a
|
|
|
|
|
// plain-text fallback part from an HTML compose body (multipart/alternative) and to
|
|
|
|
|
// quote a plain-text-only original message's body when replying/forwarding.
|
|
|
|
|
var plainTextPolicy = bluemonday.StrictPolicy()
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-15 16:31:27 +01:00
|
|
|
// management UI, and never move (see db.StandardMailboxFolders).
|
2026-08-14 13:04:55 +01:00
|
|
|
func isStandardFolder(name string) bool {
|
2026-08-15 16:31:27 +01:00
|
|
|
for _, f := range db.StandardMailboxFolders {
|
2026-08-14 13:04:55 +01:00
|
|
|
if f == name {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// allFoldersFor is the full folder list for a mailbox — see db.AllFoldersForMailbox.
|
2026-08-14 13:04:55 +01:00
|
|
|
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
|
2026-08-15 16:31:27 +01:00
|
|
|
return a.DB.AllFoldersForMailbox(mailboxID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// folderIcon picks a sidebar icon (a Bootstrap Icons class) that matches what each
|
|
|
|
|
// standard folder actually is, instead of the same generic folder glyph for all of
|
|
|
|
|
// them — INBOX and every custom folder keep the generic one.
|
|
|
|
|
func folderIcon(name string) string {
|
|
|
|
|
switch name {
|
|
|
|
|
case "Trash":
|
|
|
|
|
return "bi-trash3"
|
|
|
|
|
case "Sent":
|
|
|
|
|
return "bi-send"
|
|
|
|
|
case "Drafts":
|
|
|
|
|
return "bi-journal-text"
|
|
|
|
|
case "Junk":
|
|
|
|
|
return "bi-shield-exclamation"
|
|
|
|
|
case "INBOX":
|
|
|
|
|
return "bi-inbox"
|
|
|
|
|
default:
|
|
|
|
|
return "bi-folder2"
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
out := make([]*folderViewNode, 0, len(nodes))
|
|
|
|
|
for _, n := range nodes {
|
|
|
|
|
out = append(out, build(n))
|
|
|
|
|
}
|
|
|
|
|
return out
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// folderRow adds template-ready fields to a listed message so webmail_folder.html
|
|
|
|
|
// stays dumb (no string-searching Flags, no subject-comparison logic, itself).
|
|
|
|
|
type folderRow struct {
|
|
|
|
|
db.MailboxMessage
|
2026-08-15 16:31:27 +01:00
|
|
|
Unread bool
|
|
|
|
|
Starred bool
|
2026-08-14 13:04:55 +01:00
|
|
|
// 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
|
|
|
|
|
// toggle is clicked.
|
|
|
|
|
GroupExtra int
|
|
|
|
|
Collapsed bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
// 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
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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 {
|
2026-08-15 12:35:44 +01:00
|
|
|
v := url.Values{}
|
|
|
|
|
dir := "desc"
|
|
|
|
|
if activeSortBy == col {
|
|
|
|
|
if activeSortDir == "asc" {
|
|
|
|
|
dir = "desc"
|
|
|
|
|
} else {
|
|
|
|
|
dir = "asc"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if col != "" {
|
|
|
|
|
v.Set("sort", col)
|
|
|
|
|
}
|
|
|
|
|
if dir != "desc" {
|
|
|
|
|
v.Set("dir", dir)
|
|
|
|
|
}
|
|
|
|
|
if unreadOnly {
|
|
|
|
|
v.Set("unread", "1")
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if starredOnly {
|
|
|
|
|
v.Set("starred", "1")
|
|
|
|
|
}
|
2026-08-15 12:35:44 +01:00
|
|
|
if encoded := v.Encode(); encoded != "" {
|
|
|
|
|
return "?" + encoded
|
|
|
|
|
}
|
|
|
|
|
return "?"
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
func isUnread(flags string) bool {
|
|
|
|
|
for _, f := range strings.Fields(flags) {
|
|
|
|
|
if f == `\Seen` {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
// 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
|
|
|
|
|
// unrelated messages that happen to share a subject long after each other in
|
|
|
|
|
// mailbox history won't either, since grouping only ever joins consecutive rows in
|
|
|
|
|
// the already-sorted page (see groupConsecutiveBySubject). That's the accepted
|
|
|
|
|
// tradeoff for not needing a schema change or store-time header parsing.
|
|
|
|
|
func normalizeSubjectForGrouping(subject string) string {
|
|
|
|
|
s := strings.TrimSpace(subject)
|
|
|
|
|
for {
|
|
|
|
|
lower := strings.ToLower(s)
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(lower, "re:"):
|
|
|
|
|
s = strings.TrimSpace(s[3:])
|
|
|
|
|
case strings.HasPrefix(lower, "fwd:"):
|
|
|
|
|
s = strings.TrimSpace(s[4:])
|
|
|
|
|
case strings.HasPrefix(lower, "fw:"):
|
|
|
|
|
s = strings.TrimSpace(s[3:])
|
|
|
|
|
default:
|
|
|
|
|
return strings.ToLower(s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// groupConsecutiveBySubject annotates rows in place (same order, same length) with
|
|
|
|
|
// GroupExtra/Collapsed rather than restructuring them into a nested shape — the
|
|
|
|
|
// template can then render it exactly like a flat row list, just hiding Collapsed
|
|
|
|
|
// rows by default and showing a "+N more" toggle on the row above them.
|
|
|
|
|
func groupConsecutiveBySubject(rows []folderRow) []folderRow {
|
|
|
|
|
out := make([]folderRow, len(rows))
|
|
|
|
|
copy(out, rows)
|
|
|
|
|
i := 0
|
|
|
|
|
for i < len(out) {
|
|
|
|
|
key := normalizeSubjectForGrouping(out[i].CachedSubject)
|
|
|
|
|
j := i + 1
|
|
|
|
|
for key != "" && j < len(out) && normalizeSubjectForGrouping(out[j].CachedSubject) == key {
|
|
|
|
|
out[j].Collapsed = true
|
|
|
|
|
j++
|
|
|
|
|
}
|
|
|
|
|
out[i].GroupExtra = j - i - 1
|
|
|
|
|
i = j
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailMailRoot sends a bare /webmail/mail visit to the inbox — there's no
|
|
|
|
|
// meaningful "all folders" view.
|
|
|
|
|
func (a *App) webmailMailRoot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailFolderView lists one folder's messages, newest first, paginated — or, when
|
|
|
|
|
// ?q= is set, a search across that folder's (or every folder's, if active_folder is
|
|
|
|
|
// empty — see webmailSearch) cached subject/from/to instead.
|
|
|
|
|
func (a *App) webmailFolderView(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
a.renderFolderOrSearch(w, r, r.PathValue("folder"), strings.TrimSpace(r.URL.Query().Get("q")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderFolderOrSearch is shared by webmailFolderView (folder browsing) and
|
|
|
|
|
// webmailSearch (all-folders search, webmail_search.go) — same page template, same
|
|
|
|
|
// pagination shape, differing only in which folder (if any) is scoped and whether a
|
|
|
|
|
// query narrows the result set.
|
|
|
|
|
func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folder, query string) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
|
|
|
|
|
folders, err := a.allFoldersFor(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("list folders for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
folderTreeRaw, err := a.DB.FolderTree(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("build folder tree for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
2026-08-15 12:35:44 +01:00
|
|
|
folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
folderTree := buildFolderView(folderTreeRaw, folder, unreadCounts, folderCounts)
|
2026-08-14 13:04:55 +01:00
|
|
|
|
|
|
|
|
page := atoi(r.URL.Query().Get("page"))
|
|
|
|
|
if page < 1 {
|
|
|
|
|
page = 1
|
|
|
|
|
}
|
|
|
|
|
offset := (page - 1) * webmailPageSize
|
2026-08-15 12:35:44 +01:00
|
|
|
unreadOnly := r.URL.Query().Get("unread") == "1"
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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"
|
2026-08-14 13:04:55 +01:00
|
|
|
|
|
|
|
|
var total int
|
|
|
|
|
var rows []db.MailboxMessage
|
|
|
|
|
if query != "" {
|
|
|
|
|
total, err = a.DB.CountSearchMessagesInFolder(mbox.ID, folder, query)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("count search results for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
|
|
|
|
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
|
|
|
|
|
} else {
|
2026-08-15 16:31:27 +01:00
|
|
|
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly, starredOnly)
|
2026-08-14 13:04:55 +01:00
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, starredOnly, sortBy, sortDir, offset, webmailPageSize)
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error loading messages")
|
|
|
|
|
}
|
|
|
|
|
messages := make([]folderRow, 0, len(rows))
|
|
|
|
|
for _, m := range rows {
|
2026-08-15 16:31:27 +01:00
|
|
|
messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags), Starred: isStarred(m.Flags)})
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
// 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,
|
2026-08-15 12:35:44 +01:00
|
|
|
// single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
|
|
|
|
|
// per-mailbox display preference, toggled from Account.
|
|
|
|
|
if query == "" && folder != "" && mbox.GroupMessages {
|
2026-08-14 13:04:55 +01:00
|
|
|
messages = groupConsecutiveBySubject(messages)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
unreadToggleV := url.Values{}
|
|
|
|
|
if !unreadOnly {
|
|
|
|
|
unreadToggleV.Set("unread", "1")
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if starredOnly {
|
|
|
|
|
unreadToggleV.Set("starred", "1")
|
|
|
|
|
}
|
2026-08-15 12:35:44 +01:00
|
|
|
if sortBy != "" {
|
|
|
|
|
unreadToggleV.Set("sort", sortBy)
|
|
|
|
|
}
|
|
|
|
|
if sortDir != "" {
|
|
|
|
|
unreadToggleV.Set("dir", sortDir)
|
|
|
|
|
}
|
|
|
|
|
unreadOnlyHref := "?" + unreadToggleV.Encode()
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
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()
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
pageHref := func(n int) string {
|
|
|
|
|
v := url.Values{}
|
|
|
|
|
v.Set("page", strconv.Itoa(n))
|
|
|
|
|
if unreadOnly {
|
|
|
|
|
v.Set("unread", "1")
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if starredOnly {
|
|
|
|
|
v.Set("starred", "1")
|
|
|
|
|
}
|
2026-08-15 12:35:44 +01:00
|
|
|
if sortBy != "" {
|
|
|
|
|
v.Set("sort", sortBy)
|
|
|
|
|
}
|
|
|
|
|
if sortDir != "" {
|
|
|
|
|
v.Set("dir", sortDir)
|
|
|
|
|
}
|
|
|
|
|
return "?" + v.Encode()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
a.render(w, r, "webmail_folder.html", M{
|
2026-08-15 16:31:27 +01:00
|
|
|
"mailbox": mbox, "folders": folders, "folder_tree": folderTree, "active_folder": folder,
|
2026-08-14 13:04:55 +01:00
|
|
|
"messages": messages, "page": page, "total": total,
|
|
|
|
|
"has_next": offset+len(rows) < total, "has_prev": page > 1,
|
2026-08-15 12:35:44 +01:00
|
|
|
"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
|
2026-08-15 16:31:27 +01:00
|
|
|
"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),
|
2026-08-15 12:35:44 +01:00
|
|
|
"unread_only_href": unreadOnlyHref,
|
2026-08-15 16:31:27 +01:00
|
|
|
"starred_only_href": starredOnlyHref,
|
2026-08-15 12:35:44 +01:00
|
|
|
"prev_href": pageHref(page - 1),
|
|
|
|
|
"next_href": pageHref(page + 1),
|
|
|
|
|
"flashes": popFlashes(w, r),
|
2026-08-14 13:04:55 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
// loadMessageForView decrypts, parses, and marks one message read — the shared core
|
|
|
|
|
// behind both webmailMessageView (the full standalone page, for direct links/
|
|
|
|
|
// bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
|
|
|
|
|
// view's Outlook-style reading pane) so the crypto/parse/mark-read logic exists in
|
|
|
|
|
// exactly one place. Redirects and returns ok=false itself on any failure, so callers
|
|
|
|
|
// just need to bail out when ok is false.
|
|
|
|
|
func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *db.Mailbox, folder string, uid int64) (data M, ok bool) {
|
2026-08-14 13:04:55 +01:00
|
|
|
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
|
|
|
|
|
if !ok {
|
2026-08-15 12:35:44 +01:00
|
|
|
return nil, false
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
|
|
|
|
|
setFlash(w, "error", "Error loading message")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
2026-08-15 12:35:44 +01:00
|
|
|
return nil, false
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
|
|
|
|
|
parsed, err := mailview.Parse(unwrapped)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
|
|
|
|
|
setFlash(w, "error", "Error reading message")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
2026-08-15 12:35:44 +01:00
|
|
|
return nil, false
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isUnread(msgRow.Flags) {
|
|
|
|
|
newFlags := strings.TrimSpace(msgRow.Flags + ` \Seen`)
|
|
|
|
|
if err := a.DB.SetMessageFlags(mbox.ID, uid, newFlags); err != nil {
|
|
|
|
|
a.Logger.Error("mark message %d read: %v", uid, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
folders, _ := a.allFoldersFor(mbox.ID)
|
2026-08-15 18:38:11 +01:00
|
|
|
senderEmail := extractAddress(parsed.Header.From)
|
2026-08-14 13:04:55 +01:00
|
|
|
var htmlBody template.HTML
|
2026-08-15 18:38:11 +01:00
|
|
|
imagesBlocked := false
|
2026-08-14 13:04:55 +01:00
|
|
|
if parsed.HTMLBody != "" {
|
2026-08-15 18:38:11 +01:00
|
|
|
sanitized := htmlBodyPolicy.Sanitize(inlineContentIDImages(parsed.HTMLBody, parsed.Attachments))
|
|
|
|
|
if a.shouldShowRemoteImages(r, mbox, senderEmail) {
|
|
|
|
|
htmlBody = template.HTML(sanitized)
|
|
|
|
|
} else {
|
|
|
|
|
cleaned, blocked := stripRemoteImages(sanitized)
|
|
|
|
|
htmlBody = template.HTML(cleaned)
|
|
|
|
|
imagesBlocked = blocked
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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"
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
return M{
|
2026-08-15 16:31:27 +01:00
|
|
|
"mailbox": mbox, "folders": folders, "active_folder": folder, "under_trash": underTrash,
|
2026-08-14 13:04:55 +01:00
|
|
|
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
|
2026-08-15 18:38:11 +01:00
|
|
|
"images_blocked": imagesBlocked, "sender_email": senderEmail,
|
2026-08-14 13:04:55 +01:00
|
|
|
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
|
2026-08-15 12:35:44 +01:00
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 18:38:11 +01:00
|
|
|
// extractAddress pulls the bare address out of a "Name <addr@example.com>" or plain
|
|
|
|
|
// "addr@example.com" header value — returns "" if it doesn't parse, rather than
|
|
|
|
|
// falling back to the raw string, since callers use this for exact-match lookups
|
|
|
|
|
// (trusted-sender list, the mark-as-junk filter rule) where a malformed value would
|
|
|
|
|
// otherwise silently create a useless rule/list entry.
|
|
|
|
|
func extractAddress(raw string) string {
|
|
|
|
|
addr, err := mail.ParseAddress(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return strings.ToLower(addr.Address)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// inlineContentIDImages resolves cid: references (RFC 2392 — how an HTML email body
|
|
|
|
|
// points at an image carried as a sibling MIME part rather than a remote URL, e.g.
|
|
|
|
|
// <img src="cid:abc123@domain">) to a data: URI embedding that attachment's own
|
|
|
|
|
// bytes. Without this, htmlBodyPolicy.Sanitize silently drops the src entirely — cid:
|
|
|
|
|
// isn't an http(s)/data scheme it allows — so the image just never renders and the
|
|
|
|
|
// same bytes only ever show up in the Attachments list, duplicated and disconnected
|
|
|
|
|
// from where the sender actually placed them in the body. Attachments without a
|
|
|
|
|
// Content-Id are untouched; this never affects real download-only attachments.
|
|
|
|
|
func inlineContentIDImages(html string, attachments []mailview.Attachment) string {
|
|
|
|
|
for _, att := range attachments {
|
|
|
|
|
if att.ContentID == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
dataURI := "data:" + att.ContentType + ";base64," + base64.StdEncoding.EncodeToString(att.Data)
|
|
|
|
|
html = strings.ReplaceAll(html, "cid:"+att.ContentID, dataURI)
|
|
|
|
|
}
|
|
|
|
|
return html
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// shouldShowRemoteImages decides whether a message's remote images render live or get
|
|
|
|
|
// stripped (see stripRemoteImages) — "always" mode never blocks; "trusted" mode shows
|
|
|
|
|
// only for a sender on the mailbox's own trusted list; "ask" (default) mode only shows
|
|
|
|
|
// when this specific request explicitly asked to reveal them once (?show_images=1 —
|
|
|
|
|
// see webmailMessagePane/webmailMessageView), which never persists past that one view.
|
|
|
|
|
func (a *App) shouldShowRemoteImages(r *http.Request, mbox *db.Mailbox, senderEmail string) bool {
|
|
|
|
|
if r.URL.Query().Get("show_images") == "1" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch mbox.RemoteImagesMode {
|
|
|
|
|
case "always":
|
|
|
|
|
return true
|
|
|
|
|
case "trusted":
|
|
|
|
|
if senderEmail == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
trusted, err := a.DB.IsTrustedImageSender(mbox.ID, senderEmail)
|
|
|
|
|
return err == nil && trusted
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
// webmailMessageView renders one message as its own full page — direct links/
|
|
|
|
|
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
|
|
|
|
|
// is how it's normally opened now.
|
|
|
|
|
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
uid := int64(atoi(r.PathValue("uid")))
|
|
|
|
|
|
|
|
|
|
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data["flashes"] = popFlashes(w, r)
|
|
|
|
|
a.render(w, r, "webmail_message.html", data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailMessagePane is webmailMessageView's bare-fragment twin — AJAX-fetched into
|
|
|
|
|
// the folder view's reading pane (see webmail_folder.html) instead of navigating to a
|
|
|
|
|
// whole new page, mirroring the admin dashboard's message-log modal (view_message.go).
|
|
|
|
|
func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
uid := int64(atoi(r.PathValue("uid")))
|
|
|
|
|
|
|
|
|
|
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
a.render(w, r, "webmail_message_pane.html", data)
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-15 18:38:11 +01:00
|
|
|
// webmailAlwaysAllowImages is the "Always show images from this sender" action
|
|
|
|
|
// offered alongside the per-message "Show images" reveal — adds the message's own
|
|
|
|
|
// sender to the trusted-image-senders list (see IsTrustedImageSender) and redirects
|
|
|
|
|
// to the standalone message view with images shown immediately, not just from here on
|
|
|
|
|
// — a full-page navigation either way (from the pane or the standalone view), same as
|
|
|
|
|
// the existing Delete/Move/Restore actions already do from the reading pane.
|
|
|
|
|
func (a *App) webmailAlwaysAllowImages(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
|
|
|
|
|
}
|
|
|
|
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
|
|
|
|
dest := MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10) + "?show_images=1"
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error loading message")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
|
|
|
|
parsed, err := mailview.Parse(unwrapped)
|
|
|
|
|
senderEmail := ""
|
|
|
|
|
if err == nil {
|
|
|
|
|
senderEmail = extractAddress(parsed.Header.From)
|
|
|
|
|
}
|
|
|
|
|
if senderEmail == "" {
|
|
|
|
|
setFlash(w, "error", "Could not determine the sender's address")
|
|
|
|
|
} else if err := a.DB.AddTrustedImageSender(mbox.ID, senderEmail); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error adding sender")
|
|
|
|
|
} else {
|
|
|
|
|
// The trusted list only actually gets consulted in "trusted" mode (see
|
|
|
|
|
// shouldShowRemoteImages) — in "ask" mode, adding a sender here would silently
|
|
|
|
|
// do nothing next time despite the success message below, so upgrade "ask" to
|
|
|
|
|
// "trusted" here too. Never downgrades "always" (already shows everyone).
|
|
|
|
|
if mbox.RemoteImagesMode == "ask" {
|
|
|
|
|
a.DB.SetMailboxRemoteImagesMode(mbox.ID, "trusted")
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", "Images from "+senderEmail+" will show automatically from now on")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, dest, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
|
|
|
|
|
// this mailbox, or isn't in the folder the URL claims — mirrors the admin side's
|
|
|
|
|
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
|
|
|
|
|
// as authorization, always re-check server-side.
|
|
|
|
|
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
|
2026-08-15 12:35:44 +01:00
|
|
|
msg, ok := a.messageAccessible(mailboxID, folder, uid)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return msg, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// messageAccessible is webmailMessageWithAccess without the side effect of writing a
|
|
|
|
|
// 404 response — for webmailBulkAction, where one stale/mismatched uid among a batch
|
|
|
|
|
// selected from the page's own checkboxes should just be skipped, not abort (and
|
|
|
|
|
// double-write a response for) the whole request.
|
|
|
|
|
func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
|
2026-08-14 13:04:55 +01:00
|
|
|
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
|
|
|
|
|
if err != nil || msg == nil || msg.Folder != folder {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return msg, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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).
|
2026-08-14 13:04:55 +01:00
|
|
|
func (a *App) webmailMessageDelete(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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
if root, err := a.DB.FolderRoot(mbox.ID, folder); err == nil && root == "Trash" {
|
2026-08-14 13:04:55 +01:00
|
|
|
if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error deleting message")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", "Message permanently deleted")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if err := a.DB.MoveMessageToTrash(mbox.ID, uid); err != nil {
|
2026-08-14 13:04:55 +01:00
|
|
|
setFlash(w, "error", "Error moving message to Trash")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", "Message moved to Trash")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 21:49:25 +01:00
|
|
|
// webmailMarkAsJunk moves one message to Junk and, unless already there, adds the
|
|
|
|
|
// sender to the mailbox's own Blocklist (esrv_mailbox_allowblock, list_type "junk")
|
|
|
|
|
// so future mail from them routes straight to Junk at delivery time (db.IsJunked,
|
|
|
|
|
// checked in smtpserver's deliverLocally) without needing to build a filter rule by
|
|
|
|
|
// hand — the "blacklist" the sender asked for, visible/removable from the Blocklist
|
|
|
|
|
// page in Settings.
|
2026-08-15 18:38:11 +01:00
|
|
|
func (a *App) webmailMarkAsJunk(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
|
|
|
|
|
}
|
|
|
|
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error loading message")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
|
|
|
|
parsed, parseErr := mailview.Parse(unwrapped)
|
|
|
|
|
|
|
|
|
|
if err := a.DB.MoveMessage(mbox.ID, uid, "Junk"); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error marking as junk")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
msg := "Message marked as junk"
|
|
|
|
|
if parseErr == nil {
|
|
|
|
|
if senderEmail := extractAddress(parsed.Header.From); senderEmail != "" {
|
2026-08-15 21:49:25 +01:00
|
|
|
// AddAllowBlockEntry's own INSERT OR IGNORE (backed by the table's UNIQUE
|
|
|
|
|
// constraint) already makes this idempotent — marking several messages
|
|
|
|
|
// from the same repeat sender as junk doesn't pile up duplicate entries.
|
|
|
|
|
alreadyBlocked, err := a.DB.IsJunked(mbox.ID, senderEmail)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("check blocklist for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
|
|
|
|
} else if !alreadyBlocked {
|
|
|
|
|
if _, err := a.DB.AddAllowBlockEntry(mbox.ID, "junk", senderEmail); err != nil {
|
|
|
|
|
a.Logger.Error("add blocklist entry for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
|
|
|
|
} else {
|
|
|
|
|
msg = "Message marked as junk — future mail from " + senderEmail + " will go there too (see Blocklist to undo)"
|
|
|
|
|
}
|
2026-08-15 18:38:11 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", msg)
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
// 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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
target := strings.TrimSpace(r.FormValue("target_folder"))
|
|
|
|
|
if target == "" {
|
|
|
|
|
setFlash(w, "error", "Choose a folder to move to")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder+"/"+strconv.FormatInt(uid, 10), http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.MoveMessage(mbox.ID, uid, target); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error moving message")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", "Message moved to "+target)
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 12:35:44 +01:00
|
|
|
// webmailBulkAction applies one action (delete/move/read/unread) to every uid selected
|
|
|
|
|
// via the folder view's checkboxes — the Outlook-style toolbar's bulk equivalent of
|
|
|
|
|
// webmailMessageDelete/webmailMessageMove/the auto-mark-read-on-open behavior, all
|
|
|
|
|
// through one endpoint rather than four near-identical ones. Every uid is
|
|
|
|
|
// independently re-checked against this mailbox+folder (webmailMessageWithAccess) —
|
|
|
|
|
// the folder path segment is never trusted as authorization by itself, same as the
|
|
|
|
|
// single-message actions.
|
|
|
|
|
func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
setFlash(w, "error", "Invalid form data")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
action := r.FormValue("action")
|
|
|
|
|
target := strings.TrimSpace(r.FormValue("target_folder"))
|
|
|
|
|
if action == "move" && target == "" {
|
|
|
|
|
setFlash(w, "error", "Choose a folder to move to")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
n := 0
|
|
|
|
|
for _, uidStr := range r.Form["uid"] {
|
|
|
|
|
uid := int64(atoi(uidStr))
|
|
|
|
|
if uid == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
|
|
|
|
|
// A mismatched/stale uid here just means stale client state (the page's
|
|
|
|
|
// own checkboxes) — skip it, don't hard-fail the whole batch over one bad
|
|
|
|
|
// entry the way the single-message actions correctly do for a URL-level uid.
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var err error
|
|
|
|
|
switch action {
|
|
|
|
|
case "delete":
|
2026-08-15 16:31:27 +01:00
|
|
|
if root, rootErr := a.DB.FolderRoot(mbox.ID, folder); rootErr == nil && root == "Trash" {
|
2026-08-15 12:35:44 +01:00
|
|
|
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
|
|
|
|
|
} else {
|
2026-08-15 16:31:27 +01:00
|
|
|
err = a.DB.MoveMessageToTrash(mbox.ID, uid)
|
2026-08-15 12:35:44 +01:00
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
case "restore":
|
|
|
|
|
err = a.DB.RestoreMessage(mbox.ID, uid)
|
2026-08-15 12:35:44 +01:00
|
|
|
case "move":
|
|
|
|
|
err = a.DB.MoveMessage(mbox.ID, uid, target)
|
|
|
|
|
case "read":
|
|
|
|
|
err = a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`)
|
|
|
|
|
case "unread":
|
|
|
|
|
err = a.DB.SetMessageFlags(mbox.ID, uid, "")
|
|
|
|
|
default:
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("bulk %s on message %d for mailbox %d: %v", action, uid, mbox.ID, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
n++
|
|
|
|
|
}
|
|
|
|
|
if n > 0 {
|
|
|
|
|
setFlash(w, "success", fmt.Sprintf("%d message(s) updated", n))
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "error", "No messages were selected")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
|
|
|
|
|
// download — there's no separate on-disk attachment cache, and message sizes on a
|
|
|
|
|
// self-hosted mail server are small enough that this is simpler than building one.
|
|
|
|
|
func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
uid := int64(atoi(r.PathValue("uid")))
|
|
|
|
|
idx := atoi(r.PathValue("idx"))
|
|
|
|
|
|
|
|
|
|
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
|
|
|
|
parsed, err := mailview.Parse(unwrapped)
|
|
|
|
|
if err != nil || idx < 0 || idx >= len(parsed.Attachments) {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
att := parsed.Attachments[idx]
|
|
|
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(att.Filename, `"`, "")+`"`)
|
|
|
|
|
w.Header().Set("Content-Type", att.ContentType)
|
|
|
|
|
w.Write(att.Data)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 18:38:11 +01:00
|
|
|
// webmailDownloadAllAttachments bundles every attachment on one message into a single
|
|
|
|
|
// ZIP — a stdlib archive/zip, no new dependency — rather than making the user click
|
|
|
|
|
// each attachment separately.
|
|
|
|
|
func (a *App) webmailDownloadAllAttachments(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
|
|
|
|
|
}
|
|
|
|
|
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
|
|
|
|
parsed, err := mailview.Parse(unwrapped)
|
|
|
|
|
if err != nil || len(parsed.Attachments) == 0 {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Disposition", `attachment; filename="attachments.zip"`)
|
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
|
|
|
zw := zip.NewWriter(w)
|
|
|
|
|
usedNames := map[string]int{}
|
|
|
|
|
for _, att := range parsed.Attachments {
|
|
|
|
|
name := att.Filename
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = "attachment"
|
|
|
|
|
}
|
|
|
|
|
// Two attachments sharing a filename (unusual but not disallowed by any MIME
|
|
|
|
|
// rule) would otherwise silently overwrite each other inside the zip.
|
|
|
|
|
if usedNames[name] > 0 {
|
|
|
|
|
ext := filepath.Ext(name)
|
|
|
|
|
name = strings.TrimSuffix(name, ext) + fmt.Sprintf(" (%d)", usedNames[name]) + ext
|
|
|
|
|
}
|
|
|
|
|
usedNames[att.Filename]++
|
|
|
|
|
f, err := zw.Create(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("zip attachment %q for message %d, mailbox %d: %v", name, uid, mbox.ID, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
f.Write(att.Data)
|
|
|
|
|
}
|
|
|
|
|
zw.Close()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
const maxFolderNameLen = 60
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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.
|
2026-08-14 13:04:55 +01:00
|
|
|
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
2026-08-15 16:31:27 +01:00
|
|
|
parent := strings.TrimSpace(r.FormValue("parent"))
|
|
|
|
|
if parent == "" {
|
|
|
|
|
parent = "INBOX"
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
|
|
|
|
|
fail := func(msg string) {
|
|
|
|
|
setFlash(w, "error", msg)
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case name == "":
|
|
|
|
|
fail("Folder name is required")
|
|
|
|
|
return
|
|
|
|
|
case len(name) > maxFolderNameLen:
|
|
|
|
|
fail("Folder name is too long")
|
|
|
|
|
return
|
2026-08-15 16:31:27 +01:00
|
|
|
case strings.Contains(name, "/"):
|
|
|
|
|
fail("Folder name can't contain \"/\"")
|
|
|
|
|
return
|
2026-08-14 13:04:55 +01:00
|
|
|
case isStandardFolder(name):
|
|
|
|
|
fail(name + " already exists")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
existing, err := a.allFoldersFor(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail("Error creating folder")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
found := false
|
2026-08-14 13:04:55 +01:00
|
|
|
for _, f := range existing {
|
|
|
|
|
if strings.EqualFold(f, name) {
|
|
|
|
|
fail("A folder named " + f + " already exists")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if strings.EqualFold(f, parent) {
|
|
|
|
|
parent = f
|
|
|
|
|
found = true
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
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 {
|
2026-08-14 13:04:55 +01:00
|
|
|
fail("Error creating folder")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", "Folder "+name+" created")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 16:31:27 +01:00
|
|
|
// 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.
|
2026-08-14 13:04:55 +01:00
|
|
|
func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
|
|
|
|
|
if isStandardFolder(name) {
|
|
|
|
|
setFlash(w, "error", name+" is a standard folder and can't be removed")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
if err := a.DB.MoveFolderToTrash(mbox.ID, name); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error deleting folder")
|
2026-08-14 13:04:55 +01:00
|
|
|
} else {
|
2026-08-15 16:31:27 +01:00
|
|
|
setFlash(w, "success", "Folder "+name+" moved to Trash")
|
2026-08-14 13:04:55 +01:00
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
|
|
|
|
|
}
|
2026-08-15 16:31:27 +01:00
|
|
|
|
|
|
|
|
// webmailRenameFolder renames a folder from the sidebar's right-click context menu —
|
|
|
|
|
// only a custom folder still under INBOX (not INBOX itself, not Junk/Sent/Drafts, and
|
|
|
|
|
// not a folder that's been deleted into Trash — that subtree is a historical record,
|
|
|
|
|
// not something to keep editing). A single-row name change is enough: children
|
|
|
|
|
// reference their parent by row id, not by name, so they stay correctly nested
|
|
|
|
|
// without any further writes (see db.RenameMailboxFolder).
|
|
|
|
|
func (a *App) webmailRenameFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
oldName := r.PathValue("name")
|
|
|
|
|
newName := strings.TrimSpace(r.FormValue("new_name"))
|
|
|
|
|
|
|
|
|
|
fail := func(msg string) {
|
|
|
|
|
setFlash(w, "error", msg)
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+oldName, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
if root, err := a.DB.FolderRoot(mbox.ID, oldName); err != nil || isStandardFolder(oldName) || root != "INBOX" {
|
|
|
|
|
fail(oldName + " can't be renamed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case newName == "":
|
|
|
|
|
fail("Folder name is required")
|
|
|
|
|
return
|
|
|
|
|
case len(newName) > maxFolderNameLen:
|
|
|
|
|
fail("Folder name is too long")
|
|
|
|
|
return
|
|
|
|
|
case strings.Contains(newName, "/"):
|
|
|
|
|
fail("Folder name can't contain \"/\"")
|
|
|
|
|
return
|
|
|
|
|
case isStandardFolder(newName):
|
|
|
|
|
fail(newName + " already exists")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
existing, err := a.allFoldersFor(mbox.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail("Error renaming folder")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for _, f := range existing {
|
|
|
|
|
if strings.EqualFold(f, newName) && !strings.EqualFold(f, oldName) {
|
|
|
|
|
fail("A folder named " + f + " already exists")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.RenameMailboxFolder(mbox.ID, oldName, newName); err != nil {
|
|
|
|
|
fail("Error renaming folder")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", "Folder renamed to "+newName)
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+newName, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailMarkAllRead marks every unread message in one folder as read — the sidebar
|
|
|
|
|
// context menu's "Mark all as read", available on every folder including INBOX.
|
|
|
|
|
func (a *App) webmailMarkAllRead(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
if err := a.DB.MarkAllReadInFolder(mbox.ID, folder); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error marking messages as read")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", "All messages in "+folder+" marked as read")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// deleteFolderSubtreePermanently permanently deletes every message across start and
|
|
|
|
|
// every folder nested under it — ciphertext, index row, and freed quota, via the same
|
|
|
|
|
// per-message Mailstore.DeleteMessage path webmailMessageDelete already uses for a
|
|
|
|
|
// single already-in-Trash message — and removes the esrv_mailbox_folders records for
|
|
|
|
|
// whatever's now empty, so a permanently-deleted folder doesn't linger as an empty
|
|
|
|
|
// shell the way a merely-emptied one does. removeStartRecord controls whether start's
|
|
|
|
|
// OWN record is removed too: false for the 5 standard folders (Empty Trash/Clean up
|
|
|
|
|
// Junk — they always exist, nothing to remove even if a stray row happens to), true
|
|
|
|
|
// for a genuine custom folder someone is permanently deleting from within Trash.
|
|
|
|
|
func (a *App) deleteFolderSubtreePermanently(mbox *db.Mailbox, start string, removeStartRecord bool) (int, error) {
|
|
|
|
|
names, err := a.DB.FolderSubtreeNames(mbox.ID, start)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
n := 0
|
|
|
|
|
for _, name := range names {
|
|
|
|
|
rows, err := a.DB.ListMessagesInFolder(mbox.ID, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
a.Logger.Error("list messages in %s for mailbox %d: %v", name, mbox.ID, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, m := range rows {
|
|
|
|
|
if err := a.Mailstore.DeleteMessage(mbox.ID, m.ID); err != nil {
|
|
|
|
|
a.Logger.Error("permanently delete message %d for mailbox %d: %v", m.ID, mbox.ID, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
n++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
toRemove := make([]string, 0, len(names))
|
|
|
|
|
for _, name := range names {
|
|
|
|
|
if name == start && !removeStartRecord {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
toRemove = append(toRemove, name)
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.DeleteFolderRows(mbox.ID, toRemove); err != nil {
|
|
|
|
|
a.Logger.Error("remove folder records for mailbox %d: %v", mbox.ID, err)
|
|
|
|
|
}
|
|
|
|
|
return n, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailEmptyTrash permanently deletes every message in Trash and anything deleted
|
|
|
|
|
// into it, and removes those now-empty folder records too (see
|
|
|
|
|
// deleteFolderSubtreePermanently) — Trash-only, enforced server-side (the sidebar
|
|
|
|
|
// only ever shows this action on Trash, but the route itself doesn't trust that).
|
|
|
|
|
func (a *App) webmailEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
if folder != "Trash" {
|
|
|
|
|
setFlash(w, "error", "Only Trash can be emptied")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n, err := a.deleteFolderSubtreePermanently(mbox, "Trash", false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error emptying Trash")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", fmt.Sprintf("Trash emptied (%d message(s) permanently deleted)", n))
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailCleanSpam permanently deletes every message in Junk — no move to Trash, no
|
|
|
|
|
// recovery (see deleteFolderSubtreePermanently). Junk-only, enforced server-side.
|
|
|
|
|
func (a *App) webmailCleanSpam(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
if folder != "Junk" {
|
|
|
|
|
setFlash(w, "error", "Only Junk can be cleaned up")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n, err := a.deleteFolderSubtreePermanently(mbox, "Junk", false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error cleaning up Junk")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Junk", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setFlash(w, "success", fmt.Sprintf("Junk cleaned up (%d message(s) permanently deleted)", n))
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Junk", http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailDeleteFolderPermanently permanently removes one specific folder that's
|
|
|
|
|
// already been deleted into Trash (and whatever's nested under it) — messages and
|
|
|
|
|
// all, no further move, no recovery. Only a folder currently under Trash can be
|
|
|
|
|
// removed this way (checked server-side); a folder still under INBOX goes through
|
|
|
|
|
// webmailDeleteFolder (move to Trash) instead — permanently deleting an active folder
|
|
|
|
|
// isn't offered directly, only after it's already been trashed once.
|
|
|
|
|
func (a *App) webmailDeleteFolderPermanently(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
|
|
|
|
|
if isStandardFolder(name) {
|
|
|
|
|
setFlash(w, "error", name+" can't be removed")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if root, err := a.DB.FolderRoot(mbox.ID, name); err != nil || root != "Trash" {
|
|
|
|
|
setFlash(w, "error", "Only a folder already in Trash can be permanently deleted")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n, err := a.deleteFolderSubtreePermanently(mbox, name, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
setFlash(w, "error", "Error deleting folder")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", fmt.Sprintf("Folder %s permanently deleted (%d message(s))", name, n))
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailRestoreFolder puts a folder that's currently under Trash back where it was
|
|
|
|
|
// before it was deleted (or under INBOX if that's no longer known — see
|
|
|
|
|
// db.RestoreFolder). Only a folder currently under Trash can be restored this way.
|
|
|
|
|
func (a *App) webmailRestoreFolder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
name := r.PathValue("name")
|
|
|
|
|
|
|
|
|
|
if root, err := a.DB.FolderRoot(mbox.ID, name); err != nil || root != "Trash" {
|
|
|
|
|
setFlash(w, "error", "Only a folder in Trash can be restored")
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.RestoreFolder(mbox.ID, name); err != nil {
|
|
|
|
|
setFlash(w, "error", "Error restoring folder")
|
|
|
|
|
} else {
|
|
|
|
|
setFlash(w, "success", "Folder "+name+" restored")
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// webmailToggleStar flips \Flagged on one message — the message list's star icon.
|
|
|
|
|
func (a *App) webmailToggleStar(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
folder := r.PathValue("folder")
|
|
|
|
|
uid := int64(atoi(r.PathValue("uid")))
|
|
|
|
|
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.ToggleMessageStarred(mbox.ID, uid); err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maxFolderOrderEntries bounds a single reorder request — the sidebar only ever
|
|
|
|
|
// shows a handful of folders, so a much larger list is either a stale client or
|
|
|
|
|
// something malformed, not a real drag-and-drop.
|
|
|
|
|
const maxFolderOrderEntries = 200
|
|
|
|
|
|
|
|
|
|
// webmailSetFolderOrder persists the sidebar's full drag-and-drop order — called
|
|
|
|
|
// once per drag, with the complete current folder list top to bottom (see
|
|
|
|
|
// db.SetFolderOrder). No redirect/flash: the client already reflects the order it
|
|
|
|
|
// just dragged into place, this call is purely to persist it for next page load.
|
|
|
|
|
func (a *App) webmailSetFolderOrder(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mbox := mailboxFromContext(r)
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
order := r.Form["order"]
|
|
|
|
|
if len(order) == 0 || len(order) > maxFolderOrderEntries {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for i, name := range order {
|
|
|
|
|
order[i] = strings.TrimSpace(name)
|
|
|
|
|
if order[i] == "" || len(order[i]) > maxFolderNameLen {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := a.DB.SetFolderOrder(mbox.ID, order); err != nil {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|