Files
mailgoserver/internal/webui/webmail_mail.go
T

434 lines
16 KiB
Go

package webui
import (
"html/template"
"net/http"
"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()
// standardMailFolders are always shown in the folder sidebar even when empty — the
// rest of a mailbox's folder list is whatever filter-rule move_to_folder actions (or,
// later, explicit folder creation) have actually produced messages in.
var standardMailFolders = []string{"INBOX", "Spam", "Sent", "Drafts", "Trash"}
func mergeFolders(custom []string) []string {
seen := make(map[string]bool, len(standardMailFolders)+len(custom))
out := make([]string, 0, len(standardMailFolders)+len(custom))
for _, f := range standardMailFolders {
seen[f] = true
out = append(out, f)
}
for _, f := range custom {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// isStandardFolder reports whether name is one of the built-in folders every mailbox
// always has — these can never be created, renamed, or deleted through the folder
// management UI.
func isStandardFolder(name string) bool {
for _, f := range standardMailFolders {
if f == name {
return true
}
}
return false
}
// allFoldersFor is the full folder list for a mailbox: standard folders, plus every
// folder that either holds at least one message (DistinctFoldersForMailbox) or was
// explicitly created and is still empty (ListMailboxFolders) — a folder can exist via
// either path, sometimes both.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
fromMessages, err := a.DB.DistinctFoldersForMailbox(mailboxID)
if err != nil {
return nil, err
}
explicit, err := a.DB.ListMailboxFolders(mailboxID)
if err != nil {
return nil, err
}
return mergeFolders(append(fromMessages, explicit...)), nil
}
// 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
// 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
}
func isUnread(flags string) bool {
for _, f := range strings.Fields(flags) {
if f == `\Seen` {
return false
}
}
return true
}
// 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)
}
unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
}
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * webmailPageSize
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)
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, 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)})
}
// 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.
if query == "" && folder != "" {
messages = groupConsecutiveBySubject(messages)
}
a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "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,
"flashes": popFlashes(w, r),
})
}
// webmailMessageView decrypts, parses, and renders one message — and marks it read.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok {
return
}
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
}
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
}
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)
var htmlBody template.HTML
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
a.render(w, r, "webmail_message.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
"flashes": popFlashes(w, r),
})
}
// 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, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil || msg.Folder != folder {
http.NotFound(w, r)
return nil, false
}
return msg, true
}
// webmailMessageDelete moves a message to Trash — or, if it's already in Trash,
// permanently deletes it (ciphertext, index row, and frees the quota).
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 folder == "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.MoveMessage(mbox.ID, uid, "Trash"); 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)
}
// 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)
}
// 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)
}
const maxFolderNameLen = 60
// webmailAddFolder creates a new custom folder from the sidebar's "+ New folder"
// form. A standard folder name, an empty name, or a name that already exists is
// rejected with a flash rather than silently accepted/ignored.
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := strings.TrimSpace(r.FormValue("name"))
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 isStandardFolder(name):
fail(name + " already exists")
return
}
existing, err := a.allFoldersFor(mbox.ID)
if err != nil {
fail("Error creating folder")
return
}
for _, f := range existing {
if strings.EqualFold(f, name) {
fail("A folder named " + f + " already exists")
return
}
}
if err := a.DB.CreateMailboxFolder(mbox.ID, name); err != nil {
fail("Error creating folder")
return
}
setFlash(w, "success", "Folder "+name+" created")
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}
// webmailDeleteFolder removes a custom folder, moving any messages still in it to
// INBOX first — a folder is never left holding mail nothing can browse to anymore.
// Standard folders (checked server-side, not just hidden client-side) can't be
// removed this way.
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.MoveAllMessagesInFolder(mbox.ID, name, "INBOX"); err != nil {
setFlash(w, "error", "Error removing folder")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
setFlash(w, "error", "Error removing folder")
} else {
setFlash(w, "success", "Folder "+name+" removed — any mail in it moved to INBOX")
}
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}