updated webclient setttings
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAddAllowBlockJunkEntryWorksAfterMigration reproduces the same class of bug as
|
||||
// the filter-rules CHECK migrations: a DB created before 'junk' was added to
|
||||
// esrv_mailbox_allowblock's list_type CHECK constraint (the self-service webmail
|
||||
// Blocklist feature) kept the old, narrower constraint forever, since SQLite can't
|
||||
// ALTER a CHECK on an existing table.
|
||||
func TestAddAllowBlockJunkEntryWorksAfterMigration(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_allowblock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block')),
|
||||
pattern TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, list_type, pattern)
|
||||
)
|
||||
`); 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_allowblock (mailbox_id, list_type, pattern) VALUES (1, 'allow', 'trusted@example.com')
|
||||
`); 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.AddAllowBlockEntry(1, "junk", "spammer@example.com"); err != nil {
|
||||
t.Fatalf("AddAllowBlockEntry with 'junk' after migrating a legacy DB: %v", err)
|
||||
}
|
||||
|
||||
entries, err := database.ListAllowBlock(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected the pre-existing allow entry to survive the table rebuild alongside the new junk one, got %d entries", len(entries))
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.ListType == "allow" && e.Pattern == "trusted@example.com" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("pre-existing allow entry's data was not preserved across the migration: %+v", entries)
|
||||
}
|
||||
|
||||
junked, err := database.IsJunked(1, "spammer@example.com")
|
||||
if err != nil || !junked {
|
||||
t.Fatalf("expected spammer@example.com to be junked, got junked=%v err=%v", junked, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const contactColumns = `id, mailbox_id, email, name, phone, created_at`
|
||||
|
||||
func scanContact(scan func(dest ...any) error) (MailboxContact, error) {
|
||||
var c MailboxContact
|
||||
var createdAt string
|
||||
err := scan(&c.ID, &c.MailboxID, &c.Email, &c.Name, &c.Phone, &createdAt)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListContacts returns a mailbox's saved contacts, alphabetical by name.
|
||||
func (d *DB) ListContacts(mailboxID int64) ([]MailboxContact, error) {
|
||||
rows, err := d.Query(`SELECT `+contactColumns+` FROM esrv_mailbox_contacts WHERE mailbox_id = ? ORDER BY name COLLATE NOCASE`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxContact
|
||||
for rows.Next() {
|
||||
c, err := scanContact(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetContactByID scopes the lookup to mailboxID so one mailbox can never read or (via
|
||||
// UpdateContact/DeleteContact, which reuse this same WHERE clause) modify another's
|
||||
// contact by guessing an id.
|
||||
func (d *DB) GetContactByID(mailboxID, id int64) (*MailboxContact, error) {
|
||||
row := d.QueryRow(`SELECT `+contactColumns+` FROM esrv_mailbox_contacts WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
c, err := scanContact(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateContact(mailboxID int64, email, name, phone string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_contacts (mailbox_id, email, name, phone) VALUES (?, ?, ?, ?)`, mailboxID, email, name, phone)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_contacts SET email = ?, name = ?, phone = ? WHERE id = ? AND mailbox_id = ?`, email, name, phone, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteContact(mailboxID, id int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_contacts WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestContactCreateUpdateDelete exercises the full contact CRUD flow.
|
||||
func TestContactCreateUpdateDelete(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
id, err := d.CreateContact(mailboxID, "jane@example.com", "Jane Doe", "555-1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.GetContactByID(mailboxID, id)
|
||||
if err != nil || got == nil || got.Name != "Jane Doe" || got.Phone != "555-1234" {
|
||||
t.Fatalf("expected created contact, got %+v (err=%v)", got, err)
|
||||
}
|
||||
|
||||
if err := d.UpdateContact(mailboxID, id, "jane@example.com", "Jane D.", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := d.GetContactByID(mailboxID, id)
|
||||
if err != nil || updated == nil || updated.Name != "Jane D." || updated.Phone != "" {
|
||||
t.Fatalf("expected updated contact with phone cleared, got %+v (err=%v)", updated, err)
|
||||
}
|
||||
|
||||
if err := d.DeleteContact(mailboxID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone, err := d.GetContactByID(mailboxID, id)
|
||||
if err != nil || gone != nil {
|
||||
t.Fatalf("expected contact deleted, got %+v (err=%v)", gone, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContactEmailUniquePerMailbox confirms the UNIQUE(mailbox_id, email) constraint
|
||||
// rejects a second contact with the same email in the same mailbox.
|
||||
func TestContactEmailUniquePerMailbox(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if _, err := d.CreateContact(mailboxID, "dup@example.com", "First", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.CreateContact(mailboxID, "dup@example.com", "Second", ""); err == nil {
|
||||
t.Fatal("expected a UNIQUE constraint error for a duplicate email in the same mailbox")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuggestRecipientsIncludesContacts confirms a saved contact shows up in compose
|
||||
// autocomplete, formatted "Name <email>" like the message-history-derived entries.
|
||||
func TestSuggestRecipientsIncludesContacts(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
if _, err := d.CreateContact(mailboxID, "alice@example.com", "Alice Smith", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
suggestions, err := d.SuggestRecipients(mailboxID, "Alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(suggestions) != 1 || suggestions[0] != "Alice Smith <alice@example.com>" {
|
||||
t.Fatalf("expected contact suggested as 'Alice Smith <alice@example.com>', got %+v", suggestions)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,13 @@ func (d *DB) IsAllowed(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "allow", senderAddr)
|
||||
}
|
||||
|
||||
// IsJunked reports whether senderAddr matches the mailbox owner's own self-service
|
||||
// Blocklist ('junk' entries — see esrv_mailbox_allowblock's schema comment for how
|
||||
// this differs from admin's IsBlocked).
|
||||
func (d *DB) IsJunked(mailboxID int64, senderAddr string) (bool, error) {
|
||||
return matchesAllowBlock(d, mailboxID, "junk", senderAddr)
|
||||
}
|
||||
|
||||
// matchesAllowBlock checks senderAddr against every pattern of listType for mailboxID
|
||||
// — an exact address match, or a "@domain.com" wildcard matching senderAddr's domain.
|
||||
func matchesAllowBlock(d *DB, mailboxID int64, listType, senderAddr string) (bool, error) {
|
||||
|
||||
@@ -349,9 +349,10 @@ func (d *DB) CountMessagesByFolder(mailboxID int64) (map[string]int, error) {
|
||||
// SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
|
||||
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
|
||||
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
|
||||
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders — whose
|
||||
// value contains prefix. Backs the compose recipient autocomplete; deliberately
|
||||
// reuses message history already stored rather than a dedicated contacts table.
|
||||
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders, plus its
|
||||
// saved address book (esrv_mailbox_contacts, formatted the same "Name <addr>" way so
|
||||
// a contact's name is searchable and shown, not just its address) — whose value
|
||||
// contains prefix. Backs the compose recipient autocomplete.
|
||||
func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error) {
|
||||
like := "%" + escapeLike(prefix) + "%"
|
||||
rows, err := d.Query(`
|
||||
@@ -359,9 +360,11 @@ func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error)
|
||||
SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != ''
|
||||
UNION
|
||||
SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != ''
|
||||
UNION
|
||||
SELECT name || ' <' || email || '>' AS addr FROM esrv_mailbox_contacts WHERE mailbox_id = ?
|
||||
)
|
||||
WHERE addr LIKE ? ESCAPE '\'
|
||||
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, like)
|
||||
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, mailboxID, like)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,38 +2,59 @@ package db
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const filterRuleColumns = `id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at`
|
||||
|
||||
func scanFilterRule(scan func(dest ...any) error) (MailboxFilterRule, error) {
|
||||
var r MailboxFilterRule
|
||||
var createdAt string
|
||||
err := scan(&r.ID, &r.MailboxID, &r.Name, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.ActionOptionsJSON, &r.IsActive, &r.ConditionsJSON, &r.MatchType, &createdAt)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.CreatedAt, _ = parseTime(createdAt)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
|
||||
rows, err := d.Query(`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 WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||
rows, err := d.Query(`SELECT `+filterRuleColumns+` FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []MailboxFilterRule
|
||||
for rows.Next() {
|
||||
var r MailboxFilterRule
|
||||
var createdAt string
|
||||
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &r.ConditionsJSON, &r.MatchType, &createdAt); err != nil {
|
||||
r, err := scanFilterRule(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt, _ = parseTime(createdAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetRuleByID scopes the lookup to mailboxID, mirroring GetSignatureByID — backs the
|
||||
// rule builder's edit mode (?edit=<id>).
|
||||
func (d *DB) GetRuleByID(mailboxID, id int64) (*MailboxFilterRule, error) {
|
||||
row := d.QueryRow(`SELECT `+filterRuleColumns+` FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
r, err := scanFilterRule(row.Scan)
|
||||
if err != nil {
|
||||
return nil, nil // sql.ErrNoRows and any scan error both just mean "not found/not yours"
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// CreateRule creates a single-condition rule — a thin wrapper over CreateRuleMulti
|
||||
// for the common one-condition case (and for existing callers/tests written before
|
||||
// multi-condition rules existed).
|
||||
func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) {
|
||||
return d.CreateRuleMulti(mailboxID, priority, []RuleCondition{{Field: field, Op: op, Value: value}}, "all", action, actionValue)
|
||||
return d.CreateRuleMulti(mailboxID, priority, []RuleCondition{{Field: field, Op: op, Value: value}}, "all", "", action, actionValue, "")
|
||||
}
|
||||
|
||||
// CreateRuleMulti creates a rule with one or more conditions combined per matchType
|
||||
// ("all"=AND, "any"=OR, defaulting to "all" for anything else). The first condition
|
||||
// also mirrors into the legacy condition_field/op/value columns so old code paths
|
||||
// reading them directly still see something sane.
|
||||
func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCondition, matchType, action, actionValue string) (int64, error) {
|
||||
func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCondition, matchType, name, action, actionValue, actionOptionsJSON string) (int64, error) {
|
||||
if matchType != "any" {
|
||||
matchType = "all"
|
||||
}
|
||||
@@ -42,14 +63,38 @@ func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCon
|
||||
return 0, err
|
||||
}
|
||||
first := conditions[0]
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, conditions_json, match_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, first.Field, first.Op, first.Value, action, actionValue, string(conditionsJSON), matchType)
|
||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, conditions_json, match_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, name, priority, first.Field, first.Op, first.Value, action, actionValue, actionOptionsJSON, string(conditionsJSON), matchType)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateRuleMulti overwrites an existing rule's fields in place — used by the rule
|
||||
// builder's edit mode, scoped to mailboxID so one mailbox can never modify another's
|
||||
// rule by guessing an id (mirrors UpdateSignature).
|
||||
func (d *DB) UpdateRuleMulti(mailboxID, id int64, priority int, conditions []RuleCondition, matchType, name, action, actionValue, actionOptionsJSON string) error {
|
||||
if matchType != "any" {
|
||||
matchType = "all"
|
||||
}
|
||||
conditionsJSON, err := json.Marshal(conditions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
first := conditions[0]
|
||||
_, err = d.Exec(`UPDATE esrv_mailbox_filter_rules SET name = ?, priority = ?, condition_field = ?, condition_op = ?, condition_value = ?, action = ?, action_value = ?, action_options_json = ?, conditions_json = ?, match_type = ?
|
||||
WHERE id = ? AND mailbox_id = ?`, name, priority, first.Field, first.Op, first.Value, action, actionValue, actionOptionsJSON, string(conditionsJSON), matchType, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRuleActive flips a rule's enabled/disabled state — a quick toggle from the rules
|
||||
// list, no need to open the full edit builder just to pause a rule.
|
||||
func (d *DB) SetRuleActive(id, mailboxID int64, active bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_filter_rules SET is_active = ? WHERE id = ? AND mailbox_id = ?`, active, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveRule deletes a rule, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||
func (d *DB) RemoveRule(id, mailboxID int64) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
|
||||
@@ -3,10 +3,21 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const signatureColumns = `id, mailbox_id, name, content_html, is_default_new, is_default_reply, created_at`
|
||||
|
||||
// signatureColumnsPrefixed is signatureColumns qualified with a table alias, for
|
||||
// queries that JOIN esrv_mailbox_signatures against another table.
|
||||
func signatureColumnsPrefixed(alias string) string {
|
||||
cols := strings.Split(signatureColumns, ", ")
|
||||
for i, c := range cols {
|
||||
cols[i] = alias + "." + c
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
func scanSignature(scan func(dest ...any) error) (MailboxSignature, error) {
|
||||
var s MailboxSignature
|
||||
var createdAt string
|
||||
@@ -51,9 +62,26 @@ func (d *DB) GetSignatureByID(mailboxID, id int64) (*MailboxSignature, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GetDefaultSignature returns the mailbox's default-for-new (forReply=false) or
|
||||
// GetDefaultSignature returns the default-for-new (forReply=false) or
|
||||
// default-for-reply/forward (forReply=true) signature, or nil if none is set.
|
||||
func (d *DB) GetDefaultSignature(mailboxID int64, forReply bool) (*MailboxSignature, error) {
|
||||
// forEmail scopes this to a specific send-as alias — see
|
||||
// esrv_mailbox_signature_alias_defaults' schema comment: an alias with its own
|
||||
// override uses that; forEmail == "" (or the mailbox's own primary address, which
|
||||
// never has its own override row) falls straight through to the mailbox-wide
|
||||
// is_default_new/is_default_reply columns, same as an alias with no override does.
|
||||
func (d *DB) GetDefaultSignature(mailboxID int64, forReply bool, forEmail string) (*MailboxSignature, error) {
|
||||
if forEmail != "" {
|
||||
row := d.QueryRow(`SELECT `+signatureColumnsPrefixed("s")+`
|
||||
FROM esrv_mailbox_signature_alias_defaults ad JOIN esrv_mailbox_signatures s ON s.id = ad.signature_id
|
||||
WHERE ad.mailbox_id = ? AND ad.for_email = ? AND ad.for_reply = ?`, mailboxID, forEmail, forReply)
|
||||
s, err := scanSignature(row.Scan)
|
||||
if err == nil {
|
||||
return &s, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
col := "is_default_new"
|
||||
if forReply {
|
||||
col = "is_default_reply"
|
||||
@@ -83,6 +111,10 @@ func (d *DB) UpdateSignature(mailboxID, id int64, name, contentHTML string) erro
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSignature(mailboxID, id int64) error {
|
||||
// Best-effort — a leftover alias-default row pointing at a deleted signature
|
||||
// would just silently fail its JOIN in GetDefaultSignature (falls through to
|
||||
// the mailbox-wide default), but there's no reason to leave it dangling.
|
||||
d.Exec(`DELETE FROM esrv_mailbox_signature_alias_defaults WHERE signature_id = ?`, id)
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
@@ -105,3 +137,40 @@ func (d *DB) SetDefaultSignature(mailboxID, id int64, forReply bool) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 1 WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSignatureAliasDefaults returns every per-alias default override for a mailbox
|
||||
// (across all its signatures) — used to render "default for <alias>" badges on the
|
||||
// signatures list without a query per signature.
|
||||
func (d *DB) ListSignatureAliasDefaults(mailboxID int64) ([]SignatureAliasDefault, error) {
|
||||
rows, err := d.Query(`SELECT id, mailbox_id, for_email, for_reply, signature_id FROM esrv_mailbox_signature_alias_defaults WHERE mailbox_id = ?`, mailboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SignatureAliasDefault
|
||||
for rows.Next() {
|
||||
var s SignatureAliasDefault
|
||||
if err := rows.Scan(&s.ID, &s.MailboxID, &s.ForEmail, &s.ForReply, &s.SignatureID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetSignatureAliasDefault makes signatureID the default-for-new/default-for-reply
|
||||
// signature when composing as forEmail (a send-as alias) — upserts so re-setting the
|
||||
// same scope just repoints it rather than erroring on the UNIQUE constraint.
|
||||
func (d *DB) SetSignatureAliasDefault(mailboxID, signatureID int64, forEmail string, forReply bool) error {
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_signature_alias_defaults (mailbox_id, for_email, for_reply, signature_id) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(mailbox_id, for_email, for_reply) DO UPDATE SET signature_id = excluded.signature_id`,
|
||||
mailboxID, forEmail, forReply, signatureID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearSignatureAliasDefault removes forEmail's default-for-new/default-for-reply
|
||||
// override, falling back to the mailbox-wide default again.
|
||||
func (d *DB) ClearSignatureAliasDefault(mailboxID int64, forEmail string, forReply bool) error {
|
||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_signature_alias_defaults WHERE mailbox_id = ? AND for_email = ? AND for_reply = ?`, mailboxID, forEmail, forReply)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGetDefaultSignatureFallsBackToMailboxWideDefault confirms an alias with no
|
||||
// override of its own still gets the mailbox's regular default-for-new/default-for-
|
||||
// reply signature, rather than "no signature" — the fallback chain
|
||||
// GetDefaultSignature relies on for every alias that hasn't been given its own
|
||||
// override.
|
||||
func TestGetDefaultSignatureFallsBackToMailboxWideDefault(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
id, err := d.CreateSignature(mailboxID, "Work", "<p>Work sig</p>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetDefaultSignature(mailboxID, id, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sig, err := d.GetDefaultSignature(mailboxID, false, "alias@example.com")
|
||||
if err != nil || sig == nil || sig.ID != id {
|
||||
t.Fatalf("expected fallback to the mailbox-wide default, got %+v (err=%v)", sig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDefaultSignatureAliasOverrideWins confirms an alias with its own override
|
||||
// gets that signature instead of the mailbox-wide default, even when both exist.
|
||||
func TestGetDefaultSignatureAliasOverrideWins(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
generalID, err := d.CreateSignature(mailboxID, "General", "<p>General</p>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetDefaultSignature(mailboxID, generalID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
supportID, err := d.CreateSignature(mailboxID, "Support", "<p>Support</p>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetSignatureAliasDefault(mailboxID, supportID, "support@example.com", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sig, err := d.GetDefaultSignature(mailboxID, false, "support@example.com")
|
||||
if err != nil || sig == nil || sig.ID != supportID {
|
||||
t.Fatalf("expected the alias override (Support), got %+v (err=%v)", sig, err)
|
||||
}
|
||||
|
||||
// A different, unrelated address still gets the mailbox-wide default.
|
||||
sig, err = d.GetDefaultSignature(mailboxID, false, "someone-else@example.com")
|
||||
if err != nil || sig == nil || sig.ID != generalID {
|
||||
t.Fatalf("expected the mailbox-wide default (General) for an address with no override, got %+v (err=%v)", sig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSignatureAliasDefaultUpsertsRepointing confirms re-setting the same
|
||||
// (mailbox, alias, new-or-reply) scope to a different signature repoints it instead
|
||||
// of erroring on the UNIQUE constraint.
|
||||
func TestSetSignatureAliasDefaultUpsertsRepointing(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
firstID, _ := d.CreateSignature(mailboxID, "First", "<p>First</p>")
|
||||
secondID, _ := d.CreateSignature(mailboxID, "Second", "<p>Second</p>")
|
||||
|
||||
if err := d.SetSignatureAliasDefault(mailboxID, firstID, "alias@example.com", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.SetSignatureAliasDefault(mailboxID, secondID, "alias@example.com", false); err != nil {
|
||||
t.Fatalf("expected re-setting the same scope to upsert cleanly, got: %v", err)
|
||||
}
|
||||
|
||||
sig, err := d.GetDefaultSignature(mailboxID, false, "alias@example.com")
|
||||
if err != nil || sig == nil || sig.ID != secondID {
|
||||
t.Fatalf("expected the scope repointed to Second, got %+v (err=%v)", sig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearSignatureAliasDefaultFallsBack confirms clearing an alias override falls
|
||||
// back to the mailbox-wide default again, rather than leaving "no signature".
|
||||
func TestClearSignatureAliasDefaultFallsBack(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
generalID, _ := d.CreateSignature(mailboxID, "General", "<p>General</p>")
|
||||
d.SetDefaultSignature(mailboxID, generalID, false)
|
||||
supportID, _ := d.CreateSignature(mailboxID, "Support", "<p>Support</p>")
|
||||
d.SetSignatureAliasDefault(mailboxID, supportID, "support@example.com", false)
|
||||
|
||||
if err := d.ClearSignatureAliasDefault(mailboxID, "support@example.com", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sig, err := d.GetDefaultSignature(mailboxID, false, "support@example.com")
|
||||
if err != nil || sig == nil || sig.ID != generalID {
|
||||
t.Fatalf("expected fallback to General after clearing the override, got %+v (err=%v)", sig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteSignatureCleansUpAliasDefaults confirms deleting a signature that's an
|
||||
// alias's default doesn't leave a dangling row pointing at a nonexistent signature.
|
||||
func TestDeleteSignatureCleansUpAliasDefaults(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const mailboxID = int64(1)
|
||||
|
||||
supportID, _ := d.CreateSignature(mailboxID, "Support", "<p>Support</p>")
|
||||
d.SetSignatureAliasDefault(mailboxID, supportID, "support@example.com", false)
|
||||
|
||||
if err := d.DeleteSignature(mailboxID, supportID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults, err := d.ListSignatureAliasDefaults(mailboxID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(defaults) != 0 {
|
||||
t.Fatalf("expected the alias default row cleaned up alongside the deleted signature, got %+v", defaults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateRuleForwardWorksAfterAdvancedCheckMigration reproduces the same class of
|
||||
// bug as TestCreateRuleMarkAsSpamWorksAfterLegacyCheckMigration one migration later: a
|
||||
// DB created after 'mark_as_spam' was added but before the advanced rule builder
|
||||
// (forward action, body/has_attachment/recipient_type conditions, name/
|
||||
// action_options_json columns) kept the old, narrower CHECK constraints forever,
|
||||
// since SQLite can't ALTER a CHECK on an existing table.
|
||||
func TestCreateRuleForwardWorksAfterAdvancedCheckMigration(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','mark_as_spam')),
|
||||
action_value TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
conditions_json TEXT NOT NULL DEFAULT '',
|
||||
match_type TEXT NOT NULL DEFAULT 'all',
|
||||
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() })
|
||||
|
||||
conditions := []RuleCondition{{Field: "has_attachment", Op: "equals", Value: "yes"}}
|
||||
if _, err := database.CreateRuleMulti(1, 0, conditions, "all", "Forward invoices", "forward", "billing@example.com", `{"keep_copy":true}`); err != nil {
|
||||
t.Fatalf("CreateRuleMulti with forward action + has_attachment condition 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)
|
||||
}
|
||||
}
|
||||
@@ -67,40 +67,76 @@ type MailboxAlias struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxAllowBlockEntry is one allow- or block-list pattern for a mailbox.
|
||||
// MailboxAllowBlockEntry is one allow-, block-, or junk-list pattern for a mailbox —
|
||||
// see esrv_mailbox_allowblock's schema comment for what each list_type means.
|
||||
type MailboxAllowBlockEntry struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
ListType string // "allow" | "block"
|
||||
ListType string // "allow" | "block" | "junk"
|
||||
Pattern string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxContact is one entry in a mailbox owner's own address book.
|
||||
type MailboxContact struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
Name string
|
||||
Phone string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||
// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON
|
||||
// (when non-empty) is the current multi-condition representation — see Conditions().
|
||||
type MailboxFilterRule struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Priority int
|
||||
ConditionField string // "from" | "to" | "subject"
|
||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||
ConditionValue string
|
||||
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam"
|
||||
ActionValue string
|
||||
IsActive bool
|
||||
ConditionsJSON string
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Name string
|
||||
Priority int
|
||||
ConditionField string // "from" | "to" | "subject" | "body" | "has_attachment" | "recipient_type"
|
||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||
ConditionValue string
|
||||
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam" | "forward"
|
||||
ActionValue string
|
||||
ActionOptionsJSON string
|
||||
IsActive bool
|
||||
ConditionsJSON string
|
||||
MatchType string // "all" (AND, default) | "any" (OR)
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RuleCondition is one condition within a filter rule's "if" clause.
|
||||
// RuleCondition is one condition within a filter rule's "if" clause. Value may hold
|
||||
// several "\n"-joined values (e.g. several From addresses, or several subject
|
||||
// keywords picked in the rule builder's chip input) — matches if any one does, see
|
||||
// mailstore.matchCondition.
|
||||
type RuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Op string `json:"op"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// RuleActionOptions holds action-specific parameters that only some actions need —
|
||||
// currently just "forward"'s KeepCopy. Stored as MailboxFilterRule.ActionOptionsJSON.
|
||||
type RuleActionOptions struct {
|
||||
// KeepCopy: for the "forward" action, whether a copy is also stored in this
|
||||
// mailbox (true) or the message is forwarded and dropped otherwise (false).
|
||||
// Defaults true (see ActionOptions) — the safer default is to not silently lose
|
||||
// mail just because forwarding was set up.
|
||||
KeepCopy bool `json:"keep_copy"`
|
||||
}
|
||||
|
||||
// ActionOptions parses ActionOptionsJSON, defaulting KeepCopy true for a rule created
|
||||
// before this existed (or with the field simply left unset).
|
||||
func (r MailboxFilterRule) ActionOptions() RuleActionOptions {
|
||||
opts := RuleActionOptions{KeepCopy: true}
|
||||
if r.ActionOptionsJSON != "" {
|
||||
json.Unmarshal([]byte(r.ActionOptionsJSON), &opts)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Conditions returns this rule's conditions and how they combine ("all"=AND,
|
||||
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
||||
// condition_field/op/value columns for rules created before multi-condition support
|
||||
@@ -168,6 +204,16 @@ type MailboxSignature struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SignatureAliasDefault is one per-alias override of which signature is the default
|
||||
// for a mailbox with send-as aliases — see GetDefaultSignature.
|
||||
type SignatureAliasDefault struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
ForEmail string
|
||||
ForReply bool
|
||||
SignatureID int64
|
||||
}
|
||||
|
||||
// MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key
|
||||
// pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is
|
||||
// sign-only in this codebase, so the key never protects anything beyond what the
|
||||
|
||||
+123
-11
@@ -270,36 +270,71 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_aliases (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Per-mailbox sender allow/block list. pattern is either an exact address
|
||||
-- Per-mailbox sender allow/block/junk list. pattern is either an exact address
|
||||
-- ("spam@evil.com") or a whole-domain wildcard ("@evil.com"). A single table with a
|
||||
-- list_type column, not two near-identical tables.
|
||||
-- list_type column, not three near-identical tables.
|
||||
-- 'block' is admin-only (Prefix+"/mailboxes/{id}/lists") and hard-rejects at RCPT
|
||||
-- time (db.IsBlocked, checked in smtpserver's Rcpt()) — an anti-abuse tool, not
|
||||
-- something an end user self-manages. 'junk' is the mailbox owner's own self-service
|
||||
-- Blocklist (webmail Settings, or the message view's "Mark as Junk" action) and is a
|
||||
-- *soft* block instead: mail is still accepted and delivered, just straight to Junk
|
||||
-- (db.IsJunked, checked in smtpserver's deliverLocally, bypassing spam scoring
|
||||
-- entirely the same way 'allow' does) rather than bounced. Different tools for
|
||||
-- different jobs — collapsing them into one would either strip admins of a real hard
|
||||
-- reject or hand end users the ability to bounce mail on another user's behalf.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block')),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block','junk')),
|
||||
pattern TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, list_type, pattern)
|
||||
);
|
||||
|
||||
-- A mailbox owner's own address book — deliberately separate from
|
||||
-- esrv_mailbox_smime_contacts/esrv_mailbox_pgp_contacts (those hold a certificate/key
|
||||
-- per address for signing/encryption, not a person's name/phone) and from
|
||||
-- SuggestRecipients' history-based autocomplete (mailbox_messages.go — reuses
|
||||
-- cached_to/cached_from rather than a dedicated table, so it has no name/phone either
|
||||
-- and can't be edited). name is required; phone is optional free text (no format
|
||||
-- enforced — international numbers, extensions, etc. all vary too much to validate
|
||||
-- usefully here).
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, email)
|
||||
);
|
||||
|
||||
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
|
||||
-- delivery time, before a message is encrypted and stored — so from/to/subject
|
||||
-- delivery time, before a message is encrypted and stored — so from/to/subject/body
|
||||
-- matching works against the real message, not just the plaintext cache columns below.
|
||||
-- condition_field/op/value are the legacy single-condition columns, kept for rows
|
||||
-- created before multi-condition support existed. Every rule created since then
|
||||
-- stores its full condition list in conditions_json (a JSON array of
|
||||
-- {field,op,value}) instead, combined per match_type ("all"=AND, "any"=OR); a rule
|
||||
-- with an empty conditions_json falls back to the legacy columns as a single
|
||||
-- {field,op,value}, value optionally "\n"-joined to mean "any of these" — see
|
||||
-- mailstore.matchCondition) instead, combined per match_type ("all"=AND, "any"=OR); a
|
||||
-- rule with an empty conditions_json falls back to the legacy columns as a single
|
||||
-- condition — see MailboxFilterRule.Conditions() in mailbox_models.go.
|
||||
-- has_attachment/recipient_type conditions match against a synthetic "yes"/"no" or
|
||||
-- "to"/"cc"/"bcc" header value computed at delivery time (see smtpserver's
|
||||
-- deliverLocally), not a real message header.
|
||||
-- action_options_json holds action-specific parameters that don't apply to every
|
||||
-- action (currently just "forward"'s keep_copy) — see MailboxFilterRule.ActionOptions().
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
|
||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject','body','has_attachment','recipient_type')),
|
||||
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','mark_as_spam')),
|
||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam','forward')),
|
||||
action_value TEXT NOT NULL DEFAULT '',
|
||||
action_options_json TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
conditions_json TEXT NOT NULL DEFAULT '',
|
||||
match_type TEXT NOT NULL DEFAULT 'all',
|
||||
@@ -470,6 +505,23 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_signatures (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mailbox_signatures_mailbox ON esrv_mailbox_signatures(mailbox_id);
|
||||
|
||||
-- Per-alias override of which signature is the default-for-new/default-for-reply,
|
||||
-- for a mailbox with one or more send-as aliases (esrv_mailbox_aliases) wanting a
|
||||
-- different signature depending which address they're composing as — e.g. a
|
||||
-- "Support" signature for support@ and a personal one for their own address.
|
||||
-- Purely additive on top of esrv_mailbox_signatures' own is_default_new/is_default_reply
|
||||
-- columns, which remain the fallback default (for_email = the mailbox's own primary
|
||||
-- address, or an alias with no override row here) — see GetDefaultSignature.
|
||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_signature_alias_defaults (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
for_email TEXT NOT NULL,
|
||||
for_reply INTEGER NOT NULL DEFAULT 0,
|
||||
signature_id INTEGER NOT NULL REFERENCES esrv_mailbox_signatures(id),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, for_email, for_reply)
|
||||
);
|
||||
|
||||
-- Other people's PGP public keys a mailbox owner has collected, added by hand —
|
||||
-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a
|
||||
-- recipient in compose.
|
||||
@@ -525,6 +577,12 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
`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 condition_field/action CHECK constraints (adding body/has_attachment/
|
||||
// recipient_type and forward) aren't retrofittable via ALTER TABLE either — see
|
||||
// migrateFilterRulesAdvancedCheck below. name/action_options_json themselves are
|
||||
// plain columns and migrate fine here.
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN action_options_json TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
// 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
|
||||
@@ -544,12 +602,37 @@ func migrateAddedColumns(db *sql.DB) {
|
||||
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
|
||||
migrateSpamRenamedToJunk(db)
|
||||
migrateFilterRulesMarkAsSpamCheck(db)
|
||||
migrateFilterRulesAdvancedCheck(db)
|
||||
migrateAllowBlockJunkCheck(db)
|
||||
}
|
||||
|
||||
// migrateAllowBlockJunkCheck rebuilds esrv_mailbox_allowblock for any DB created
|
||||
// before 'junk' was added to the list_type CHECK constraint (the self-service
|
||||
// webmail Blocklist feature) — same rename/recreate/copy/drop approach as
|
||||
// migrateFilterRulesMarkAsSpamCheck above.
|
||||
func migrateAllowBlockJunkCheck(db *sql.DB) {
|
||||
var tableSQL string
|
||||
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_allowblock'`).Scan(&tableSQL); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.Contains(tableSQL, "'junk'") {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE esrv_mailbox_allowblock RENAME TO esrv_mailbox_allowblock_old`); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return
|
||||
}
|
||||
db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, created_at)
|
||||
SELECT id, mailbox_id, list_type, pattern, created_at FROM esrv_mailbox_allowblock_old`)
|
||||
db.Exec(`DROP TABLE esrv_mailbox_allowblock_old`)
|
||||
}
|
||||
|
||||
// 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
|
||||
// created before 'mark_as_spam' was added to the action CHECK constraint (still a
|
||||
// valid rule-builder action today — see the Rules section of Settings) — 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.
|
||||
@@ -574,6 +657,35 @@ func migrateFilterRulesMarkAsSpamCheck(db *sql.DB) {
|
||||
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
|
||||
}
|
||||
|
||||
// migrateFilterRulesAdvancedCheck rebuilds esrv_mailbox_filter_rules for any DB created
|
||||
// before the advanced rule builder (body/has_attachment/recipient_type conditions,
|
||||
// forward action) widened condition_field/action's CHECK constraints — same
|
||||
// rename/recreate/copy/drop approach as migrateFilterRulesMarkAsSpamCheck above, and
|
||||
// deliberately run after it so a DB that predates both migrations gets caught up by
|
||||
// each in turn without either one needing to know about the other's column set.
|
||||
// name/action_options_json are plain columns already added by migrateAddedColumns
|
||||
// above by the time this runs, so the INSERT below can select them unconditionally.
|
||||
func migrateFilterRulesAdvancedCheck(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, "'forward'") {
|
||||
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, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at)
|
||||
SELECT id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, 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
|
||||
// that already had messages/records under the old name — "Junk" is what most desktop
|
||||
// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always
|
||||
|
||||
Reference in New Issue
Block a user