fixing folders and image rendering in client
This commit is contained in:
@@ -5,13 +5,13 @@ import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages`
|
||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages, remote_images_mode`
|
||||
|
||||
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
|
||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
|
||||
}
|
||||
|
||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, dm.domain_name
|
||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, m.remote_images_mode, dm.domain_name
|
||||
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||
var m MailboxWithDomain
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.DomainName); err != nil {
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &m.DomainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
var createdBy sql.NullInt64
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
|
||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
@@ -125,6 +125,14 @@ func (d *DB) SetMailboxGroupMessages(id int64, group bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMailboxRemoteImagesMode sets how remote images in HTML mail are handled — mode
|
||||
// should be "ask", "trusted", or "always" (not enforced here; webmailSetRemoteImagesMode
|
||||
// validates against that set before calling this).
|
||||
func (d *DB) SetMailboxRemoteImagesMode(id int64, mode string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET remote_images_mode = ? WHERE id = ?`, mode, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package db
|
||||
|
||||
import "strings"
|
||||
|
||||
// TrustedImageSender is one sender a mailbox owner has said to always show remote
|
||||
// images from (esrv_mailboxes.remote_images_mode = "trusted") — see schema.go's
|
||||
// comment on esrv_mailbox_trusted_image_senders.
|
||||
type TrustedImageSender struct {
|
||||
ID int64
|
||||
Email string
|
||||
}
|
||||
|
||||
// ListTrustedImageSenders returns a mailbox's trusted-sender list, alphabetically.
|
||||
func (d *DB) ListTrustedImageSenders(mailboxID int64) ([]TrustedImageSender, error) {
|
||||
rows, err := d.Query(`SELECT id, email FROM esrv_mailbox_trusted_image_senders WHERE mailbox_id = ? ORDER BY email`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TrustedImageSender
|
||||
for rows.Next() {
|
||||
var t TrustedImageSender
|
||||
if err := rows.Scan(&t.ID, &t.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddTrustedImageSender is idempotent (INSERT OR IGNORE) — adding an address already
|
||||
// on the list is a harmless no-op, not an error, since both the settings-page "Add"
|
||||
// form and the per-message "always allow this sender" checkbox can reach it.
|
||||
func (d *DB) AddTrustedImageSender(mailboxID int64, email string) error {
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_trusted_image_senders (mailbox_id, email) VALUES (?, ?)`,
|
||||
mailboxID, strings.ToLower(strings.TrimSpace(email)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) RemoveTrustedImageSender(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_trusted_image_senders WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsTrustedImageSender reports whether email is on mailboxID's trusted list —
|
||||
// case-insensitive, since email local-parts/domains are conventionally
|
||||
// case-insensitive and a header's casing is never guaranteed to match what was typed
|
||||
// into the settings page.
|
||||
func (d *DB) IsTrustedImageSender(mailboxID int64, email string) (bool, error) {
|
||||
var n int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_trusted_image_senders WHERE mailbox_id = ? AND email = ?`,
|
||||
mailboxID, strings.ToLower(strings.TrimSpace(email))).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateRuleMarkAsSpamWorksAfterLegacyCheckMigration reproduces a live bug: a DB
|
||||
// created before 'mark_as_spam' was added to esrv_mailbox_filter_rules' action CHECK
|
||||
// constraint kept the old constraint forever, since SQLite can't ALTER a CHECK on an
|
||||
// existing table — so CreateRule with action "mark_as_spam" (webmail's "Mark as Junk"
|
||||
// auto-blacklist, see webui.ensureJunkRuleForSender) failed with "CHECK constraint
|
||||
// failed: action IN ('move_to_folder','delete','mark_read')" on any pre-existing
|
||||
// installation, confirmed live via the webmail context menu.
|
||||
func TestCreateRuleMarkAsSpamWorksAfterLegacyCheckMigration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := raw.Exec(`
|
||||
CREATE TABLE esrv_mailboxes (id INTEGER PRIMARY KEY AUTOINCREMENT)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := raw.Exec(`
|
||||
CREATE TABLE esrv_mailbox_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
|
||||
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||
condition_value TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')),
|
||||
action_value TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := raw.Exec(`INSERT INTO esrv_mailboxes (id) VALUES (1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := raw.Exec(`
|
||||
INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
|
||||
VALUES (1, 0, 'subject', 'contains', 'existing-rule', 'move_to_folder', 'Archive')
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
if _, err := database.CreateRule(1, 0, "from", "contains", "spammer@example.com", "mark_as_spam", ""); err != nil {
|
||||
t.Fatalf("CreateRule with mark_as_spam after migrating a legacy DB: %v", err)
|
||||
}
|
||||
|
||||
rules, err := database.ListRulesForMailbox(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("expected the pre-existing rule to survive the table rebuild alongside the new one, got %d rules", len(rules))
|
||||
}
|
||||
found := false
|
||||
for _, r := range rules {
|
||||
if r.ConditionValue == "existing-rule" && r.ActionValue == "Archive" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("pre-existing rule's data was not preserved across the migration: %+v", rules)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ type Mailbox struct {
|
||||
// GroupMessages collapses a run of same-subject messages in a folder view into one
|
||||
// expandable row when true. Off by default — a display preference, not a policy.
|
||||
GroupMessages bool
|
||||
// RemoteImagesMode is "ask" (default), "trusted", or "always" — see the
|
||||
// remote_images_mode column comment in schema.go.
|
||||
RemoteImagesMode string
|
||||
}
|
||||
|
||||
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||
|
||||
+55
-6
@@ -6,6 +6,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -211,7 +212,15 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
|
||||
mfa_exempt INTEGER NOT NULL DEFAULT 0,
|
||||
-- Off by default: collapse a run of same-subject messages in a folder view into one
|
||||
-- expandable row. Per-mailbox, not global, since this is purely a display preference.
|
||||
group_messages INTEGER NOT NULL DEFAULT 0
|
||||
group_messages INTEGER NOT NULL DEFAULT 0,
|
||||
-- Remote (http/https) images in an HTML email body are a classic tracking-pixel /
|
||||
-- read-receipt leak, so they're never auto-loaded — this controls when they show at
|
||||
-- all: 'ask' (default) strips them and offers a per-message "Show images" reveal;
|
||||
-- 'trusted' auto-shows only for senders on this mailbox's own
|
||||
-- esrv_mailbox_trusted_image_senders list; 'always' never blocks (not recommended,
|
||||
-- offered anyway since it's the mailbox owner's own call). See
|
||||
-- webmail_mail.go's loadMessageForView / stripRemoteImages.
|
||||
remote_images_mode TEXT NOT NULL DEFAULT 'ask'
|
||||
);
|
||||
|
||||
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
||||
@@ -297,6 +306,18 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Senders a mailbox owner has explicitly said to always show remote images from
|
||||
-- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account
|
||||
-- settings page or via the "always allow images from this sender" checkbox offered
|
||||
-- alongside the per-message "Show images" reveal.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_trusted_image_senders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, email)
|
||||
);
|
||||
|
||||
-- One row per stored message. cached_from/cached_subject are deliberately plaintext
|
||||
-- (a narrow, confirmed exception to "encrypted at rest") so IMAP LIST/basic SEARCH
|
||||
-- don't need to decrypt every message in a folder; body and every other header stay
|
||||
@@ -485,11 +506,9 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`,
|
||||
// conditions_json/match_type are retrofittable via ALTER TABLE, but the action
|
||||
// CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support
|
||||
// altering a CHECK on an existing table. A dev DB created before this change
|
||||
// would need recreating to accept a mark_as_spam rule; a fresh install gets it
|
||||
// for free from the CREATE TABLE above.
|
||||
// The action CHECK constraint (adding 'mark_as_spam') isn't retrofittable via
|
||||
// ALTER TABLE — see migrateFilterRulesMarkAsSpamCheck below, called at the end
|
||||
// of this function, which rebuilds the table for DBs that predate it.
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`,
|
||||
// key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt
|
||||
@@ -505,6 +524,7 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`,
|
||||
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_root TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailboxes ADD COLUMN remote_images_mode TEXT NOT NULL DEFAULT 'ask'`,
|
||||
}
|
||||
// The three old columns above were NOT NULL with no default, so simply adding
|
||||
// key_pem left them behind still blocking every new insert (which only ever sets
|
||||
@@ -523,6 +543,35 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
// account skip its username change entirely once it re-hits /first-login next.
|
||||
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
|
||||
migrateSpamRenamedToJunk(db)
|
||||
migrateFilterRulesMarkAsSpamCheck(db)
|
||||
}
|
||||
|
||||
// migrateFilterRulesMarkAsSpamCheck rebuilds esrv_mailbox_filter_rules for any DB
|
||||
// created before 'mark_as_spam' was added to the action CHECK constraint (webmail's
|
||||
// "Mark as Junk" auto-blacklist rule, see webmail_mail.go's ensureJunkRuleForSender) —
|
||||
// SQLite can't ALTER a CHECK constraint on an existing table, so the only way to widen
|
||||
// it is to recreate the table under the current schema and copy the rows across.
|
||||
// Detects the stale constraint by inspecting sqlite_master rather than tracking a
|
||||
// schema-version number, so it stays a no-op forever once a DB is caught up.
|
||||
func migrateFilterRulesMarkAsSpamCheck(db *sql.DB) {
|
||||
var tableSQL string
|
||||
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_filter_rules'`).Scan(&tableSQL); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.Contains(tableSQL, "mark_as_spam") {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE esrv_mailbox_filter_rules RENAME TO esrv_mailbox_filter_rules_old`); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return
|
||||
}
|
||||
db.Exec(`INSERT INTO esrv_mailbox_filter_rules
|
||||
(id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at)
|
||||
SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at
|
||||
FROM esrv_mailbox_filter_rules_old`)
|
||||
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
|
||||
}
|
||||
|
||||
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
|
||||
|
||||
@@ -30,6 +30,11 @@ type Attachment struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
// ContentID is the part's Content-Id header (RFC 2392), angle brackets stripped,
|
||||
// or "" if absent. An HTML body referencing this part inline (<img src="cid:...">)
|
||||
// only renders once that reference is resolved against this — see
|
||||
// webui.inlineContentIDImages, the caller responsible for doing so.
|
||||
ContentID string
|
||||
}
|
||||
|
||||
// Message is the parsed result. TextBody/HTMLBody are independently populated when
|
||||
@@ -114,11 +119,12 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
|
||||
if filename == "" {
|
||||
filename = params["name"]
|
||||
}
|
||||
contentID := strings.Trim(part.Header.Get("Content-Id"), "<>")
|
||||
|
||||
switch {
|
||||
case disp == "attachment" || (filename != "" && disp != "inline"):
|
||||
m.Attachments = append(m.Attachments, Attachment{
|
||||
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
|
||||
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
|
||||
})
|
||||
case mediaType == "text/html":
|
||||
m.HTMLBody += string(data)
|
||||
@@ -127,12 +133,15 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
|
||||
m.TextBody += "\n"
|
||||
}
|
||||
m.TextBody += string(data)
|
||||
case filename != "":
|
||||
// Inline non-text part (e.g. an embedded image) with no explicit
|
||||
// disposition — still worth surfacing as a downloadable attachment
|
||||
// rather than silently dropping it.
|
||||
case filename != "" || contentID != "":
|
||||
// Inline non-text part (e.g. an embedded image referenced by the HTML
|
||||
// body via cid:) with no explicit disposition and possibly no filename
|
||||
// either — still worth surfacing rather than silently dropping it.
|
||||
if filename == "" {
|
||||
filename = contentID
|
||||
}
|
||||
m.Attachments = append(m.Attachments, Attachment{
|
||||
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
|
||||
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +89,49 @@ func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
|
||||
t.Fatalf("attachments = %+v", m.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineImageCapturesContentID(t *testing.T) {
|
||||
raw := "" +
|
||||
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
|
||||
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
|
||||
"--B\r\nContent-Type: text/html\r\n\r\n<p><img src=\"cid:img1@example.com\"></p>\r\n" +
|
||||
"--B\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"header.png\"\r\n" +
|
||||
"Content-Id: <img1@example.com>\r\n" +
|
||||
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
|
||||
"--B--\r\n"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(m.HTMLBody) != `<p><img src="cid:img1@example.com"></p>` {
|
||||
t.Errorf("HTMLBody = %q", m.HTMLBody)
|
||||
}
|
||||
if len(m.Attachments) != 1 {
|
||||
t.Fatalf("attachments = %+v", m.Attachments)
|
||||
}
|
||||
if m.Attachments[0].ContentID != "img1@example.com" {
|
||||
t.Errorf("ContentID = %q, want angle brackets stripped", m.Attachments[0].ContentID)
|
||||
}
|
||||
if got := string(m.Attachments[0].Data); got != "Hello, world!" {
|
||||
t.Errorf("attachment data = %q, want decoded base64", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineImageWithNoFilenameStillCaptured(t *testing.T) {
|
||||
raw := "" +
|
||||
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
|
||||
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
|
||||
"--B\r\nContent-Type: text/html\r\n\r\n<p>hi</p>\r\n" +
|
||||
"--B\r\nContent-Type: image/png\r\n" +
|
||||
"Content-Id: <noname@example.com>\r\n\r\nrawbytes" +
|
||||
"\r\n--B--\r\n"
|
||||
m, err := Parse([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No filename anywhere on the part — must not be silently dropped just because
|
||||
// there's nothing to name it; falls back to the Content-Id itself.
|
||||
if len(m.Attachments) != 1 || m.Attachments[0].ContentID != "noname@example.com" {
|
||||
t.Fatalf("attachments = %+v", m.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// stripRemoteImages walks already-sanitized HTML (htmlBodyPolicy has already removed
|
||||
// scripts/event handlers/etc. — this never runs on untrusted-for-XSS-purposes input)
|
||||
// and neutralizes any <img src="..."> that isn't a data: URI, renaming it to
|
||||
// data-blocked-src so the browser never fetches it — a classic tracking-pixel/
|
||||
// read-receipt vector otherwise. Reports whether anything was actually blocked, so
|
||||
// the caller only shows a "Show images" banner when there's something to reveal.
|
||||
//
|
||||
// This runs as a second pass over the DOM rather than trying to make bluemonday's own
|
||||
// policy conditionally reject remote img src — bluemonday composes URL-scheme rules
|
||||
// globally per policy (AllowStandardURLs), and UGCPolicy already bakes in "img src
|
||||
// follows the global scheme allowlist" internally, so cleanly restricting only img
|
||||
// src to data: URIs while leaving other elements' href/src alone isn't something the
|
||||
// policy API exposes directly. A dedicated pass keeps the two concerns (XSS
|
||||
// sanitization vs. privacy-motivated image blocking) independent and easy to reason
|
||||
// about separately.
|
||||
func stripRemoteImages(sanitizedHTML string) (cleaned string, blocked bool) {
|
||||
doc, err := html.Parse(strings.NewReader(sanitizedHTML))
|
||||
if err != nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "img" {
|
||||
for i, attr := range n.Attr {
|
||||
if attr.Key != "src" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(attr.Val, "data:") {
|
||||
break
|
||||
}
|
||||
n.Attr[i].Key = "data-blocked-src"
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if !blocked {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
// html.Parse wraps a fragment in a full document (html>head,body) — render just
|
||||
// the body's children back out, matching what was originally passed in (a
|
||||
// fragment, not a full document).
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
for c := body.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := html.Render(&buf, c); err != nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
}
|
||||
return buf.String(), true
|
||||
}
|
||||
|
||||
func findBody(n *html.Node) *html.Node {
|
||||
if n.Type == html.ElementNode && n.Data == "body" {
|
||||
return n
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if b := findBody(c); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -78,6 +78,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-image me-2"></i>Remote Images</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="form-text mb-2">HTML emails can embed images loaded from the sender's own server — a classic tracking pixel. Choose how those are handled.</div>
|
||||
<form method="POST" action="/webmail/account/remote-images-mode">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="remote_images_mode" value="ask" id="rim-ask" {{if eq .mailbox.RemoteImagesMode "ask"}}checked{{end}}>
|
||||
<label class="form-check-label" for="rim-ask">Ask to show remote images <span class="text-muted">(default)</span></label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="remote_images_mode" value="trusted" id="rim-trusted" {{if eq .mailbox.RemoteImagesMode "trusted"}}checked{{end}}>
|
||||
<label class="form-check-label" for="rim-trusted">Show automatically for senders on my trusted list below</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="radio" name="remote_images_mode" value="always" id="rim-always" {{if eq .mailbox.RemoteImagesMode "always"}}checked{{end}}>
|
||||
<label class="form-check-label" for="rim-always">Always show remote images <span class="text-danger">(not recommended)</span></label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-check-lg me-1"></i>Save</button>
|
||||
</form>
|
||||
<hr>
|
||||
<h6>Trusted senders</h6>
|
||||
<form method="POST" action="/webmail/account/trusted-senders/add" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
<input type="email" class="form-control form-control-sm" name="email" placeholder="sender@example.com" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-plus-lg me-1"></i>Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .trusted_senders}}
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .trusted_senders}}
|
||||
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||
{{.Email}}
|
||||
<form method="post" action="/webmail/account/trusted-senders/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No trusted senders yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -54,7 +54,10 @@
|
||||
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
|
||||
.msg-subject { color: #e0e0e0; }
|
||||
|
||||
#folderContextMenu { z-index: 1085; }
|
||||
#folderContextMenu, #messageContextMenu { z-index: 1085; }
|
||||
.dropdown-submenu { position: relative; }
|
||||
.dropdown-submenu > .dropdown-menu { top: 0; left: 100%; margin-left: 2px; display: none; }
|
||||
.dropdown-submenu:hover > .dropdown-menu { display: block; }
|
||||
.folder-row { display: flex; align-items: center; }
|
||||
.folder-drag-handle { cursor: grab; color: #6a6a6a; padding: 0 .25rem 0 0; flex: 0 0 auto; }
|
||||
.folder-drag-handle:hover { color: #adb5bd; }
|
||||
@@ -66,8 +69,10 @@
|
||||
.folder-children { margin-left: 16px; }
|
||||
.folder-children.collapsed { display: none; }
|
||||
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-wrap: break-word; word-break: break-word; overflow-x: auto; max-width: 100%; }
|
||||
.msg-body-html img { max-width: 100%; height: auto; }
|
||||
.msg-body-html table { max-width: 100%; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }
|
||||
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -219,6 +224,40 @@
|
||||
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
|
||||
</form>
|
||||
|
||||
{{/* Always rendered (independent of search-mode, unlike the bulk toolbar's own
|
||||
Move-to dropdown) so the message context menu's Move-to submenu always has a
|
||||
folder list to build from, search results included. */}}
|
||||
<select id="allFoldersList" class="d-none">
|
||||
{{range .folders}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
|
||||
<div id="messageContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
|
||||
<button type="button" class="dropdown-item" data-msg-action="reply"><i class="bi bi-reply me-2"></i>Reply</button>
|
||||
<button type="button" class="dropdown-item" data-msg-action="forward"><i class="bi bi-arrow-right me-2"></i>Forward</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item" data-msg-action="toggle-read"><i class="bi bi-envelope-open me-2"></i><span data-toggle-read-label>Mark as read</span></button>
|
||||
<button type="button" class="dropdown-item" data-msg-action="junk"><i class="bi bi-shield-exclamation me-2"></i>Mark as Junk</button>
|
||||
<div class="dropdown-submenu">
|
||||
<button type="button" class="dropdown-item dropdown-toggle" data-msg-action="move-toggle">Move to…</button>
|
||||
<div class="dropdown-menu" id="messageMoveSubmenu"></div>
|
||||
</div>
|
||||
<button type="button" class="dropdown-item" data-msg-action="open-tab"><i class="bi bi-box-arrow-up-right me-2"></i>Open in new tab</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button type="button" class="dropdown-item text-danger" data-msg-action="delete"><i class="bi bi-trash me-2"></i><span data-delete-label>Delete</span></button>
|
||||
</div>
|
||||
<form method="post" id="messageActionForm" class="d-none"></form>
|
||||
|
||||
<div class="modal fade" id="messageModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content" style="background-color: #2d2d2d; border-color: #404040;">
|
||||
<div class="modal-header" style="border-color: #404040;">
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="messageModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -755,6 +794,137 @@
|
||||
});
|
||||
window.addEventListener('scroll', hideMenu, true);
|
||||
})();
|
||||
|
||||
// Message row right-click menu (Reply/Forward/Mark read-unread/Mark as Junk/
|
||||
// Move to.../Open in new tab/Delete) and double-click (open in a full-size
|
||||
// modal, reusing the same pane fragment the reading pane already loads —
|
||||
// useful for a long message without losing the list). Both are additive to
|
||||
// the existing plain/Shift/Ctrl-click handling above (different event types
|
||||
// entirely — contextmenu/dblclick never fire alongside a plain click).
|
||||
(function() {
|
||||
const menu = document.getElementById('messageContextMenu');
|
||||
const submenu = document.getElementById('messageMoveSubmenu');
|
||||
const form = document.getElementById('messageActionForm');
|
||||
let menuRow = null;
|
||||
|
||||
function postMessageAction(url, fields) {
|
||||
form.action = url;
|
||||
form.querySelectorAll('input[data-dynamic]').forEach(function(el) { el.remove(); });
|
||||
const all = Object.assign({ csrf_token: window.__csrfToken || '' }, fields || {});
|
||||
Object.keys(all).forEach(function(name) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = name; input.value = all[name];
|
||||
input.dataset.dynamic = '1';
|
||||
form.appendChild(input);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function hideMenu() { menu.style.display = 'none'; menuRow = null; }
|
||||
|
||||
function buildMoveSubmenu(row) {
|
||||
submenu.innerHTML = '';
|
||||
const rowFolder = row.dataset.folder;
|
||||
Array.from(document.querySelectorAll('#allFoldersList option')).forEach(function(opt) {
|
||||
if (opt.value === rowFolder) return;
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'dropdown-item';
|
||||
item.textContent = opt.value;
|
||||
item.addEventListener('click', function() {
|
||||
hideMenu();
|
||||
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: opt.value });
|
||||
});
|
||||
submenu.appendChild(item);
|
||||
});
|
||||
const newItem = document.createElement('button');
|
||||
newItem.type = 'button';
|
||||
newItem.className = 'dropdown-item';
|
||||
newItem.innerHTML = '<i class="bi bi-folder-plus me-2"></i>New folder…';
|
||||
newItem.addEventListener('click', async function() {
|
||||
hideMenu();
|
||||
const name = await showInputPrompt('New folder name (inside "INBOX")');
|
||||
if (!name) return;
|
||||
const body = new URLSearchParams({ name: name, parent: 'INBOX', csrf_token: window.__csrfToken || '' });
|
||||
const resp = await fetch('/webmail/mail/folders/add', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
|
||||
if (resp.ok) {
|
||||
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: name });
|
||||
}
|
||||
});
|
||||
submenu.appendChild(newItem);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.msg-row').forEach(function(row) {
|
||||
row.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
if (row.dataset.isDraft === 'true') return; // drafts only open in compose
|
||||
menuRow = row;
|
||||
const isUnread = row.classList.contains('unread');
|
||||
menu.querySelector('[data-toggle-read-label]').textContent = isUnread ? 'Mark as read' : 'Mark as unread';
|
||||
const underTrash = row.dataset.folder && document.querySelector('.folder-row[data-folder="' + CSS.escape(row.dataset.folder) + '"][data-under-trash="true"]') !== null;
|
||||
menu.querySelector('[data-delete-label]').textContent = underTrash ? 'Delete Permanently' : 'Delete';
|
||||
buildMoveSubmenu(row);
|
||||
menu.style.left = e.clientX + 'px';
|
||||
menu.style.top = e.clientY + 'px';
|
||||
menu.style.display = 'block';
|
||||
});
|
||||
row.addEventListener('dblclick', function(e) {
|
||||
if (e.target.closest('.msg-check, .msg-star, .msg-group-toggle')) return;
|
||||
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
||||
const modalBody = document.getElementById('messageModalBody');
|
||||
modalBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
||||
new bootstrap.Modal(document.getElementById('messageModal')).show();
|
||||
fetch(row.dataset.paneHref)
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(function(html) { modalBody.innerHTML = html; })
|
||||
.catch(function() { modalBody.innerHTML = '<p class="text-danger p-3">Failed to load the message.</p>'; });
|
||||
});
|
||||
});
|
||||
|
||||
menu.querySelectorAll('[data-msg-action]').forEach(function(item) {
|
||||
item.addEventListener('click', async function() {
|
||||
const row = menuRow;
|
||||
const action = item.dataset.msgAction;
|
||||
if (action === 'move-toggle') return; // hover-only, handled by the submenu itself
|
||||
hideMenu();
|
||||
if (!row) return;
|
||||
const folder = row.dataset.folder, uid = row.dataset.uid;
|
||||
switch (action) {
|
||||
case 'reply':
|
||||
openCompose(`/webmail/mail/compose?reply=${uid}&folder=${folder}`);
|
||||
break;
|
||||
case 'forward':
|
||||
openCompose(`/webmail/mail/compose?forward=${uid}&folder=${folder}`);
|
||||
break;
|
||||
case 'toggle-read': {
|
||||
const isUnread = row.classList.contains('unread');
|
||||
postMessageAction(`/webmail/mail/${folder}/bulk`, { action: isUnread ? 'read' : 'unread', uid: uid });
|
||||
break;
|
||||
}
|
||||
case 'junk':
|
||||
postMessageAction(`/webmail/mail/${folder}/${uid}/mark-junk`);
|
||||
break;
|
||||
case 'open-tab':
|
||||
window.open(row.dataset.href, '_blank', 'noopener');
|
||||
break;
|
||||
case 'delete': {
|
||||
const underTrash = menu.querySelector('[data-delete-label]').textContent === 'Delete Permanently';
|
||||
const confirmMsg = underTrash ? 'Permanently delete this message? This cannot be undone.' : 'Move this message to Trash?';
|
||||
if (await showConfirmation(confirmMsg)) postMessageAction(`/webmail/mail/${folder}/${uid}/delete`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!menu.contains(e.target)) hideMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') hideMenu();
|
||||
});
|
||||
window.addEventListener('scroll', hideMenu, true);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-wrap: break-word; word-break: break-word; overflow-x: auto; max-width: 100%; }
|
||||
.msg-body-html img { max-width: 100%; height: auto; }
|
||||
.msg-body-html table { max-width: 100%; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -103,6 +105,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .images_blocked}}
|
||||
<div class="alert alert-secondary d-flex justify-content-between align-items-center py-2 mb-3">
|
||||
<span><i class="bi bi-shield-lock me-1"></i>Remote images were blocked to protect your privacy.</span>
|
||||
<span class="d-flex gap-2">
|
||||
<a href="{{.message_url}}?show_images=1" class="btn btn-sm btn-outline-secondary">Show images</a>
|
||||
{{if .sender_email}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/always-allow-images" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Always show images from {{.sender_email}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .html_body}}
|
||||
<div class="msg-body-html">{{.html_body}}</div>
|
||||
{{else if .parsed.TextBody}}
|
||||
@@ -113,7 +128,12 @@
|
||||
|
||||
{{if .parsed.Attachments}}
|
||||
<hr>
|
||||
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||
{{if gt (len .parsed.Attachments) 1}}
|
||||
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/attachments.zip" class="btn btn-sm btn-outline-secondary"><i class="bi bi-file-earmark-zip me-1"></i>Download all</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="list-group">
|
||||
{{$folder := .active_folder}}
|
||||
{{$uid := .uid}}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
|
||||
<a href="{{.message_url}}" target="_blank" rel="noopener" class="btn btn-outline-primary" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i></a>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
|
||||
@@ -72,6 +73,21 @@
|
||||
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
|
||||
</div>
|
||||
|
||||
{{if .images_blocked}}
|
||||
<div class="alert alert-secondary d-flex justify-content-between align-items-center py-2 mb-3">
|
||||
<span><i class="bi bi-shield-lock me-1"></i>Remote images were blocked to protect your privacy.</span>
|
||||
<span class="d-flex gap-2">
|
||||
<a href="{{.message_url}}?show_images=1" class="btn btn-sm btn-outline-secondary">Show images</a>
|
||||
{{if .sender_email}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/always-allow-images" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Always show images from {{.sender_email}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .html_body}}
|
||||
<div class="msg-body-html">{{.html_body}}</div>
|
||||
{{else if .parsed.TextBody}}
|
||||
@@ -82,7 +98,12 @@
|
||||
|
||||
{{if .parsed.Attachments}}
|
||||
<hr>
|
||||
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||
{{if gt (len .parsed.Attachments) 1}}
|
||||
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/attachments.zip" class="btn btn-sm btn-outline-secondary"><i class="bi bi-file-earmark-zip me-1"></i>Download all</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="list-group">
|
||||
{{$folder := .active_folder}}
|
||||
{{$uid := .uid}}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
|
||||
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
|
||||
trustedSenders, _ := a.DB.ListTrustedImageSenders(mbox.ID)
|
||||
pctFull := 0.0
|
||||
if mbox.QuotaBytes > 0 {
|
||||
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
|
||||
@@ -34,10 +35,58 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// them explicitly here instead.
|
||||
a.render(w, r, "webmail_account.html", M{
|
||||
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
||||
"flashes": popFlashes(w, r),
|
||||
"trusted_senders": trustedSenders,
|
||||
"flashes": popFlashes(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
// remoteImagesModes are the only valid values for esrv_mailboxes.remote_images_mode
|
||||
// — see its schema.go comment for what each means.
|
||||
var remoteImagesModes = map[string]bool{"ask": true, "trusted": true, "always": true}
|
||||
|
||||
func (a *App) webmailSetRemoteImagesMode(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
mode := r.FormValue("remote_images_mode")
|
||||
if !remoteImagesModes[mode] {
|
||||
setFlash(w, "error", "Invalid setting")
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxRemoteImagesMode(mbox.ID, mode); err != nil {
|
||||
setFlash(w, "error", "Could not save preference")
|
||||
} else {
|
||||
setFlash(w, "success", "Preference saved")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailAddTrustedImageSender(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
setFlash(w, "error", "Enter an email address")
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.AddTrustedImageSender(mbox.ID, email); err != nil {
|
||||
setFlash(w, "error", "Error adding sender")
|
||||
} else {
|
||||
setFlash(w, "success", email+" will now show images automatically")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailRemoveTrustedImageSender(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
id := int64(atoi(r.PathValue("id")))
|
||||
if err := a.DB.RemoveTrustedImageSender(id, mbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing sender")
|
||||
} else {
|
||||
setFlash(w, "success", "Sender removed")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
|
||||
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
|
||||
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/mailview"
|
||||
)
|
||||
|
||||
func TestInlineContentIDImagesReplacesWithDataURI(t *testing.T) {
|
||||
html := `<p><img src="cid:img1@example.com"></p>`
|
||||
attachments := []mailview.Attachment{
|
||||
{Filename: "header.png", ContentType: "image/png", Data: []byte("fake-png-bytes"), ContentID: "img1@example.com"},
|
||||
}
|
||||
got := inlineContentIDImages(html, attachments)
|
||||
if strings.Contains(got, "cid:") {
|
||||
t.Errorf("cid: reference survived: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "data:image/png;base64,") {
|
||||
t.Errorf("expected a data: URI in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineContentIDImagesLeavesUnmatchedRefsAlone(t *testing.T) {
|
||||
html := `<p><img src="cid:unknown@example.com"></p>`
|
||||
// No attachment carries this Content-Id — must not touch the src, since
|
||||
// htmlBodyPolicy.Sanitize (run right after this) already strips any src it
|
||||
// doesn't recognize, and that's the correct outcome for a genuinely missing part.
|
||||
got := inlineContentIDImages(html, nil)
|
||||
if got != html {
|
||||
t.Errorf("got %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -405,9 +409,18 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
|
||||
}
|
||||
|
||||
folders, _ := a.allFoldersFor(mbox.ID)
|
||||
senderEmail := extractAddress(parsed.Header.From)
|
||||
var htmlBody template.HTML
|
||||
imagesBlocked := false
|
||||
if parsed.HTMLBody != "" {
|
||||
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(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
|
||||
@@ -422,10 +435,66 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
|
||||
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.
|
||||
@@ -457,6 +526,49 @@ func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -534,6 +646,73 @@ func (a *App) webmailRestoreMessage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailMarkAsJunk moves one message to Junk and, unless one already exists, adds a
|
||||
// filter rule ("from" contains this sender's address -> mark_as_spam) so future mail
|
||||
// from them routes straight to Junk at delivery time (mailstore.ApplyRules) — the
|
||||
// "blacklist" the sender asked for, reusing the existing Rules feature rather than a
|
||||
// separate mechanism: it shows up, and can be removed at any time, from the same
|
||||
// Rules page as everything else.
|
||||
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 != "" {
|
||||
if added, err := a.ensureJunkRuleForSender(mbox.ID, senderEmail); err != nil {
|
||||
a.Logger.Error("create junk rule for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
||||
} else if added {
|
||||
msg = "Message marked as junk — future mail from " + senderEmail + " will go there too (see Rules to undo)"
|
||||
}
|
||||
}
|
||||
}
|
||||
setFlash(w, "success", msg)
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||
}
|
||||
|
||||
// ensureJunkRuleForSender creates a "from contains <email> -> mark_as_spam" rule
|
||||
// unless a matching one already exists — idempotent, so marking several messages
|
||||
// from the same repeat sender as junk doesn't pile up duplicate rules. Returns
|
||||
// whether a new rule was actually created (false when one already covered it).
|
||||
func (a *App) ensureJunkRuleForSender(mailboxID int64, senderEmail string) (bool, error) {
|
||||
rules, err := a.DB.ListRulesForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Action != "mark_as_spam" {
|
||||
continue
|
||||
}
|
||||
conditions, _ := rule.Conditions()
|
||||
for _, c := range conditions {
|
||||
if c.Field == "from" && strings.EqualFold(strings.TrimSpace(c.Value), senderEmail) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := a.DB.CreateRule(mailboxID, 0, "from", "contains", senderEmail, "mark_as_spam", ""); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -655,6 +834,54 @@ func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request)
|
||||
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
|
||||
|
||||
@@ -141,6 +141,9 @@ func (a *App) Mux() *http.ServeMux {
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/preferences", a.webmailSetGroupMessages)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/remote-images-mode", a.webmailSetRemoteImagesMode)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/trusted-senders/add", a.webmailAddTrustedImageSender)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/trusted-senders/{id}/remove", a.webmailRemoveTrustedImageSender)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/rebuild-cache", a.webmailRebuildMessageCache)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
|
||||
@@ -164,11 +167,14 @@ func (a *App) Mux() *http.ServeMux {
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/star", a.webmailToggleStar)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/restore", a.webmailRestoreMessage)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/mark-junk", a.webmailMarkAsJunk)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/always-allow-images", a.webmailAlwaysAllowImages)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/mark-all-read", a.webmailMarkAllRead)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/empty", a.webmailEmptyTrash)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/clean-spam", a.webmailCleanSpam)
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachments.zip", a.webmailDownloadAllAttachments)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/rename", a.webmailRenameFolder)
|
||||
|
||||
Reference in New Issue
Block a user