package webui

import (
	"archive/zip"
	"encoding/base64"
	"fmt"
	"html/template"
	"net/http"
	"net/mail"
	"net/url"
	"path/filepath"
	"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
// management UI, and never move (see db.StandardMailboxFolders).
func isStandardFolder(name string) bool {
	for _, f := range db.StandardMailboxFolders {
		if f == name {
			return true
		}
	}
	return false
}

// allFoldersFor is the full folder list for a mailbox — see db.AllFoldersForMailbox.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
	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"
	}
}

// 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
// stays dumb (no string-searching Flags, no subject-comparison logic, itself).
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
	// toggle is clicked.
	GroupExtra int
	Collapsed  bool
}

// 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/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 {
		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")
	}
	if starredOnly {
		v.Set("starred", "1")
	}
	if encoded := v.Encode(); encoded != "" {
		return "?" + encoded
	}
	return "?"
}

func isUnread(flags string) bool {
	for _, f := range strings.Fields(flags) {
		if f == `\Seen` {
			return false
		}
	}
	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
// 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)
	}
	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)
	}
	folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
	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 {
		page = 1
	}
	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"

	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 {
		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, 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), 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,
	// single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
	// per-mailbox display preference, toggled from Account.
	if query == "" && folder != "" && mbox.GroupMessages {
		messages = groupConsecutiveBySubject(messages)
	}

	unreadToggleV := url.Values{}
	if !unreadOnly {
		unreadToggleV.Set("unread", "1")
	}
	if starredOnly {
		unreadToggleV.Set("starred", "1")
	}
	if sortBy != "" {
		unreadToggleV.Set("sort", sortBy)
	}
	if sortDir != "" {
		unreadToggleV.Set("dir", sortDir)
	}
	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)
		}
		if sortDir != "" {
			v.Set("dir", sortDir)
		}
		return "?" + v.Encode()
	}

	a.render(w, r, "webmail_folder.html", M{
		"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, "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),
	})
}

// 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) {
	msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
	if !ok {
		return nil, false
	}
	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)
		return nil, false
	}
	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)
		return nil, false
	}

	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)
	senderEmail := extractAddress(parsed.Header.From)
	var htmlBody template.HTML
	imagesBlocked := false
	if parsed.HTMLBody != "" {
		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
		}
	}
	// 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, "under_trash": underTrash,
		"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
		"images_blocked": imagesBlocked, "sender_email": senderEmail,
		"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
	}, true
}

// 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
	}
}

// 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)
}

// 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)
}

// 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) {
	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) {
	msg, err := a.DB.GetMessageByUID(mailboxID, uid)
	if err != nil || msg == nil || msg.Folder != folder {
		return nil, false
	}
	return msg, true
}

// 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")
	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" {
		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
	}
	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")
	}
	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)
}

// 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.
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 != "" {
			// 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)"
				}
			}
		}
	}
	setFlash(w, "success", msg)
	http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, 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) {
	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)
}

// 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":
			if root, rootErr := a.DB.FolderRoot(mbox.ID, folder); rootErr == nil && root == "Trash" {
				err = a.Mailstore.DeleteMessage(mbox.ID, uid)
			} else {
				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":
			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)
}

// 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)
}

// 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()
}

const maxFolderNameLen = 60

// 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)
		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
	case strings.Contains(name, "/"):
		fail("Folder name can't contain \"/\"")
		return
	case isStandardFolder(name):
		fail(name + " already exists")
		return
	}
	existing, err := a.allFoldersFor(mbox.ID)
	if err != nil {
		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 !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
	}
	setFlash(w, "success", "Folder "+name+" created")
	http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}

// 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")

	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
	}
	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
	}
	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)
}
