434 lines
16 KiB
Go
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)
|
|
}
|