diff --git a/internal/db/crud_mailboxes.go b/internal/db/crud_mailboxes.go
index f19156c..4ae82d9 100644
--- a/internal/db/crud_mailboxes.go
+++ b/internal/db/crud_mailboxes.go
@@ -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
diff --git a/internal/db/crud_trusted_image_senders.go b/internal/db/crud_trusted_image_senders.go
new file mode 100644
index 0000000..f228a7a
--- /dev/null
+++ b/internal/db/crud_trusted_image_senders.go
@@ -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
+}
diff --git a/internal/db/filter_rules_mark_as_spam_migration_test.go b/internal/db/filter_rules_mark_as_spam_migration_test.go
new file mode 100644
index 0000000..b2ef634
--- /dev/null
+++ b/internal/db/filter_rules_mark_as_spam_migration_test.go
@@ -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)
+ }
+}
diff --git a/internal/db/mailbox_models.go b/internal/db/mailbox_models.go
index 3d255ee..3f8b6bd 100644
--- a/internal/db/mailbox_models.go
+++ b/internal/db/mailbox_models.go
@@ -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
diff --git a/internal/db/schema.go b/internal/db/schema.go
index f3af05b..393a5e3 100644
--- a/internal/db/schema.go
+++ b/internal/db/schema.go
@@ -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
diff --git a/internal/mailview/mailview.go b/internal/mailview/mailview.go
index 70bee41..c5c4a81 100644
--- a/internal/mailview/mailview.go
+++ b/internal/mailview/mailview.go
@@ -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 ()
+ // 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,
})
}
}
diff --git a/internal/mailview/mailview_test.go b/internal/mailview/mailview_test.go
index 1499827..e47c2f0 100644
--- a/internal/mailview/mailview_test.go
+++ b/internal/mailview/mailview_test.go
@@ -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
hi
\r\n" + + "--B\r\nContent-Type: image/png\r\n" + + "Content-Id:No trusted senders yet.
+ {{end}} +