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)
|
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
|
// matchesAllowBlock checks senderAddr against every pattern of listType for mailboxID
|
||||||
// — an exact address match, or a "@domain.com" wildcard matching senderAddr's domain.
|
// — an exact address match, or a "@domain.com" wildcard matching senderAddr's domain.
|
||||||
func matchesAllowBlock(d *DB, mailboxID int64, listType, senderAddr string) (bool, error) {
|
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
|
// 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
|
// 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
|
// 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
|
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders, plus its
|
||||||
// value contains prefix. Backs the compose recipient autocomplete; deliberately
|
// saved address book (esrv_mailbox_contacts, formatted the same "Name <addr>" way so
|
||||||
// reuses message history already stored rather than a dedicated contacts table.
|
// 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) {
|
func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error) {
|
||||||
like := "%" + escapeLike(prefix) + "%"
|
like := "%" + escapeLike(prefix) + "%"
|
||||||
rows, err := d.Query(`
|
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 != ''
|
SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != ''
|
||||||
UNION
|
UNION
|
||||||
SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != ''
|
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 '\'
|
WHERE addr LIKE ? ESCAPE '\'
|
||||||
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, like)
|
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, mailboxID, like)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,59 @@ package db
|
|||||||
|
|
||||||
import "encoding/json"
|
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) {
|
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
|
rows, err := d.Query(`SELECT `+filterRuleColumns+` FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
||||||
FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []MailboxFilterRule
|
var out []MailboxFilterRule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r MailboxFilterRule
|
r, err := scanFilterRule(rows.Scan)
|
||||||
var createdAt string
|
if err != nil {
|
||||||
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 {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r.CreatedAt, _ = parseTime(createdAt)
|
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
// CreateRule creates a single-condition rule — a thin wrapper over CreateRuleMulti
|
||||||
// for the common one-condition case (and for existing callers/tests written before
|
// for the common one-condition case (and for existing callers/tests written before
|
||||||
// multi-condition rules existed).
|
// multi-condition rules existed).
|
||||||
func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) {
|
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
|
// 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
|
// ("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
|
// also mirrors into the legacy condition_field/op/value columns so old code paths
|
||||||
// reading them directly still see something sane.
|
// 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" {
|
if matchType != "any" {
|
||||||
matchType = "all"
|
matchType = "all"
|
||||||
}
|
}
|
||||||
@@ -42,14 +63,38 @@ func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCon
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
first := conditions[0]
|
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)
|
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, priority, first.Field, first.Op, first.Value, action, actionValue, string(conditionsJSON), matchType)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, name, priority, first.Field, first.Op, first.Value, action, actionValue, actionOptionsJSON, string(conditionsJSON), matchType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
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).
|
// RemoveRule deletes a rule, scoped to mailboxID (mirrors RemoveAppPassword/RemoveAlias).
|
||||||
func (d *DB) RemoveRule(id, mailboxID int64) error {
|
func (d *DB) RemoveRule(id, mailboxID int64) error {
|
||||||
_, err := d.Exec(`DELETE FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
_, err := d.Exec(`DELETE FROM esrv_mailbox_filter_rules WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||||
|
|||||||
@@ -3,10 +3,21 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const signatureColumns = `id, mailbox_id, name, content_html, is_default_new, is_default_reply, created_at`
|
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) {
|
func scanSignature(scan func(dest ...any) error) (MailboxSignature, error) {
|
||||||
var s MailboxSignature
|
var s MailboxSignature
|
||||||
var createdAt string
|
var createdAt string
|
||||||
@@ -51,9 +62,26 @@ func (d *DB) GetSignatureByID(mailboxID, id int64) (*MailboxSignature, error) {
|
|||||||
return &s, nil
|
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.
|
// 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"
|
col := "is_default_new"
|
||||||
if forReply {
|
if forReply {
|
||||||
col = "is_default_reply"
|
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 {
|
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)
|
_, err := d.Exec(`DELETE FROM esrv_mailbox_signatures WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||||
return err
|
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)
|
_, err := d.Exec(`UPDATE esrv_mailbox_signatures SET `+col+` = 1 WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||||
return err
|
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
|
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 {
|
type MailboxAllowBlockEntry struct {
|
||||||
ID int64
|
ID int64
|
||||||
MailboxID int64
|
MailboxID int64
|
||||||
ListType string // "allow" | "block"
|
ListType string // "allow" | "block" | "junk"
|
||||||
Pattern string
|
Pattern string
|
||||||
CreatedAt time.Time
|
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.
|
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||||
// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON
|
// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON
|
||||||
// (when non-empty) is the current multi-condition representation — see Conditions().
|
// (when non-empty) is the current multi-condition representation — see Conditions().
|
||||||
type MailboxFilterRule struct {
|
type MailboxFilterRule struct {
|
||||||
ID int64
|
ID int64
|
||||||
MailboxID int64
|
MailboxID int64
|
||||||
|
Name string
|
||||||
Priority int
|
Priority int
|
||||||
ConditionField string // "from" | "to" | "subject"
|
ConditionField string // "from" | "to" | "subject" | "body" | "has_attachment" | "recipient_type"
|
||||||
ConditionOp string // "contains" | "equals" | "starts_with"
|
ConditionOp string // "contains" | "equals" | "starts_with"
|
||||||
ConditionValue string
|
ConditionValue string
|
||||||
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam"
|
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam" | "forward"
|
||||||
ActionValue string
|
ActionValue string
|
||||||
|
ActionOptionsJSON string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
ConditionsJSON string
|
ConditionsJSON string
|
||||||
MatchType string // "all" (AND, default) | "any" (OR)
|
MatchType string // "all" (AND, default) | "any" (OR)
|
||||||
CreatedAt time.Time
|
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 {
|
type RuleCondition struct {
|
||||||
Field string `json:"field"`
|
Field string `json:"field"`
|
||||||
Op string `json:"op"`
|
Op string `json:"op"`
|
||||||
Value string `json:"value"`
|
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,
|
// Conditions returns this rule's conditions and how they combine ("all"=AND,
|
||||||
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
||||||
// condition_field/op/value columns for rules created before multi-condition support
|
// condition_field/op/value columns for rules created before multi-condition support
|
||||||
@@ -168,6 +204,16 @@ type MailboxSignature struct {
|
|||||||
CreatedAt time.Time
|
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
|
// 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
|
// 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
|
// 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
|
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
|
-- ("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 (
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
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,
|
pattern TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(mailbox_id, list_type, pattern)
|
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
|
-- 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.
|
-- 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
|
-- condition_field/op/value are the legacy single-condition columns, kept for rows
|
||||||
-- created before multi-condition support existed. Every rule created since then
|
-- created before multi-condition support existed. Every rule created since then
|
||||||
-- stores its full condition list in conditions_json (a JSON array of
|
-- 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
|
-- {field,op,value}, value optionally "\n"-joined to mean "any of these" — see
|
||||||
-- with an empty conditions_json falls back to the legacy columns as a single
|
-- 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.
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
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_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||||
condition_value TEXT NOT NULL,
|
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_value TEXT NOT NULL DEFAULT '',
|
||||||
|
action_options_json TEXT NOT NULL DEFAULT '',
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
conditions_json TEXT NOT NULL DEFAULT '',
|
conditions_json TEXT NOT NULL DEFAULT '',
|
||||||
match_type TEXT NOT NULL DEFAULT 'all',
|
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);
|
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 —
|
-- 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
|
-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a
|
||||||
-- recipient in compose.
|
-- 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_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_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE esrv_mailboxes ADD COLUMN remote_images_mode TEXT NOT NULL DEFAULT 'ask'`,
|
`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
|
// 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
|
// 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)
|
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
|
||||||
migrateSpamRenamedToJunk(db)
|
migrateSpamRenamedToJunk(db)
|
||||||
migrateFilterRulesMarkAsSpamCheck(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
|
// migrateFilterRulesMarkAsSpamCheck rebuilds esrv_mailbox_filter_rules for any DB
|
||||||
// created before 'mark_as_spam' was added to the action CHECK constraint (webmail's
|
// created before 'mark_as_spam' was added to the action CHECK constraint (still a
|
||||||
// "Mark as Junk" auto-blacklist rule, see webmail_mail.go's ensureJunkRuleForSender) —
|
// valid rule-builder action today — see the Rules section of Settings) — SQLite
|
||||||
// SQLite can't ALTER a CHECK constraint on an existing table, so the only way to widen
|
// 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.
|
// 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
|
// 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.
|
// 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`)
|
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
|
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
|
||||||
// that already had messages/records under the old name — "Junk" is what most desktop
|
// 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
|
// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always
|
||||||
|
|||||||
@@ -12,13 +12,21 @@ type FilterAction struct {
|
|||||||
Folder string // non-empty: store here instead of INBOX
|
Folder string // non-empty: store here instead of INBOX
|
||||||
MarkRead bool
|
MarkRead bool
|
||||||
Drop bool // don't store at all
|
Drop bool // don't store at all
|
||||||
|
// ForwardTo: non-empty means also relay a copy of this message out to this
|
||||||
|
// address — see smtpserver's deliverLocally, which actually sends it (this
|
||||||
|
// package has no outbound SMTP capability of its own). KeepCopy controls whether
|
||||||
|
// the recipient's own local copy is still stored too.
|
||||||
|
ForwardTo string
|
||||||
|
KeepCopy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyRules evaluates a mailbox's filter rules in priority order and returns the
|
// ApplyRules evaluates a mailbox's filter rules in priority order and returns the
|
||||||
// first match's action (zero value if none match, meaning "store in INBOX, unread").
|
// first match's action (zero value if none match, meaning "store in INBOX, unread").
|
||||||
// headers should have "from"/"to"/"subject" keys — rules run at delivery time, before
|
// headers should have "from"/"to"/"subject"/"body"/"has_attachment"/"recipient_type"
|
||||||
// the message is encrypted and stored, so real header values are available, not just
|
// keys — rules run at delivery time, before the message is encrypted and stored, so
|
||||||
// the plaintext cache columns used for fast IMAP listing.
|
// real header/body values are available, not just the plaintext cache columns used
|
||||||
|
// for fast IMAP listing. has_attachment is "yes"/"no" and recipient_type is
|
||||||
|
// "to"/"cc"/"bcc", both computed by the caller rather than being real headers.
|
||||||
func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAction, error) {
|
func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAction, error) {
|
||||||
rules, err := s.DB.ListRulesForMailbox(mailboxID)
|
rules, err := s.DB.ListRulesForMailbox(mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,6 +51,8 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
|
|||||||
return FilterAction{Drop: true}, nil
|
return FilterAction{Drop: true}, nil
|
||||||
case "mark_read":
|
case "mark_read":
|
||||||
return FilterAction{MarkRead: true}, nil
|
return FilterAction{MarkRead: true}, nil
|
||||||
|
case "forward":
|
||||||
|
return FilterAction{ForwardTo: r.ActionValue, KeepCopy: r.ActionOptions().KeepCopy}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FilterAction{}, nil
|
return FilterAction{}, nil
|
||||||
@@ -68,16 +78,31 @@ func ruleMatches(r db.MailboxFilterRule, headers map[string]string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchCondition compares a header's actual value against a condition's target.
|
||||||
|
// target may hold several "\n"-joined values (the rule builder's chip input, e.g.
|
||||||
|
// multiple From addresses or multiple subject keywords) — matches if any one does,
|
||||||
|
// mirroring how a real mail client's "is any of" condition works.
|
||||||
func matchCondition(op, value, target string) bool {
|
func matchCondition(op, value, target string) bool {
|
||||||
value = strings.ToLower(value)
|
value = strings.ToLower(value)
|
||||||
target = strings.ToLower(target)
|
for _, t := range strings.Split(target, "\n") {
|
||||||
|
t = strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch op {
|
switch op {
|
||||||
case "contains":
|
case "contains":
|
||||||
return strings.Contains(value, target)
|
if strings.Contains(value, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
case "equals":
|
case "equals":
|
||||||
return value == target
|
if value == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
case "starts_with":
|
case "starts_with":
|
||||||
return strings.HasPrefix(value, target)
|
if strings.HasPrefix(value, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestApplyRulesMultiConditionAnd(t *testing.T) {
|
|||||||
{Field: "to", Op: "contains", Value: "sales"},
|
{Field: "to", Op: "contains", Value: "sales"},
|
||||||
{Field: "subject", Op: "contains", Value: "invoice"},
|
{Field: "subject", Op: "contains", Value: "invoice"},
|
||||||
}
|
}
|
||||||
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "move_to_folder", "Invoices"); err != nil {
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "move_to_folder", "Invoices", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ func TestApplyRulesMultiConditionOr(t *testing.T) {
|
|||||||
{Field: "from", Op: "contains", Value: "boss@work.example"},
|
{Field: "from", Op: "contains", Value: "boss@work.example"},
|
||||||
{Field: "subject", Op: "contains", Value: "urgent"},
|
{Field: "subject", Op: "contains", Value: "urgent"},
|
||||||
}
|
}
|
||||||
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "any", "mark_read", ""); err != nil {
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "any", "", "mark_read", "", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ func TestApplyRulesMultiConditionOr(t *testing.T) {
|
|||||||
func TestApplyRulesMarkAsSpam(t *testing.T) {
|
func TestApplyRulesMarkAsSpam(t *testing.T) {
|
||||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "viagra"}}
|
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "viagra"}}
|
||||||
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "mark_as_spam", ""); err != nil {
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "mark_as_spam", "", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +84,108 @@ func TestApplyRulesMarkAsSpam(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyRulesMultiValueConditionMatchesAny confirms a single condition holding
|
||||||
|
// several "\n"-joined values (the rule builder's chip input, e.g. several From
|
||||||
|
// addresses) matches if any one of them does.
|
||||||
|
func TestApplyRulesMultiValueConditionMatchesAny(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
conditions := []db.RuleCondition{{Field: "from", Op: "contains", Value: "aaa@gogogo.com\nflower@monster.com"}}
|
||||||
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "delete", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := s.ApplyRules(mailboxID, map[string]string{"from": "flower@monster.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !action.Drop {
|
||||||
|
t.Fatalf("expected match against the second of two chip values, got %+v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err = s.ApplyRules(mailboxID, map[string]string{"from": "someone-else@example.com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Drop {
|
||||||
|
t.Fatalf("expected no match when neither chip value matches, got %+v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyRulesBodyAndHasAttachmentConditions confirms the two synthetic condition
|
||||||
|
// fields computed by the caller (smtpserver's deliverLocally, not real headers) match
|
||||||
|
// like any other field once present in the headers map.
|
||||||
|
func TestApplyRulesBodyAndHasAttachmentConditions(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
conditions := []db.RuleCondition{
|
||||||
|
{Field: "body", Op: "contains", Value: "invoice"},
|
||||||
|
{Field: "has_attachment", Op: "equals", Value: "yes"},
|
||||||
|
}
|
||||||
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "mark_as_spam", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := s.ApplyRules(mailboxID, map[string]string{"body": "please pay this invoice", "has_attachment": "yes"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Folder != "Junk" {
|
||||||
|
t.Fatalf("expected match when both body and has_attachment match, got %+v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err = s.ApplyRules(mailboxID, map[string]string{"body": "please pay this invoice", "has_attachment": "no"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Folder == "Junk" {
|
||||||
|
t.Fatalf("expected no match when has_attachment is no, got %+v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyRulesRecipientTypeCondition confirms recipient_type (the per-delivery
|
||||||
|
// to/cc/bcc-ness computed by the caller, mirroring Outlook's "I'm on the Cc line")
|
||||||
|
// only matches deliveries of that specific type.
|
||||||
|
func TestApplyRulesRecipientTypeCondition(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
conditions := []db.RuleCondition{{Field: "recipient_type", Op: "equals", Value: "cc"}}
|
||||||
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "mark_read", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := s.ApplyRules(mailboxID, map[string]string{"recipient_type": "cc"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !action.MarkRead {
|
||||||
|
t.Fatalf("expected match on cc delivery, got %+v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err = s.ApplyRules(mailboxID, map[string]string{"recipient_type": "to"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.MarkRead {
|
||||||
|
t.Fatalf("expected no match on to delivery, got %+v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyRulesForwardActionReturnsKeepCopy confirms the forward action carries its
|
||||||
|
// destination address and keep_copy option through from ActionOptionsJSON.
|
||||||
|
func TestApplyRulesForwardActionReturnsKeepCopy(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||||
|
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "fwd"}}
|
||||||
|
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "forward", "elsewhere@example.com", `{"keep_copy":false}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "fwd: hi"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.ForwardTo != "elsewhere@example.com" || action.KeepCopy {
|
||||||
|
t.Fatalf("expected forward to elsewhere@example.com with keep_copy=false, got %+v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestApplyRulesLegacySingleConditionFallback confirms a rule row with an empty
|
// TestApplyRulesLegacySingleConditionFallback confirms a rule row with an empty
|
||||||
// ConditionsJSON (as any rule created before multi-condition support existed would
|
// ConditionsJSON (as any rule created before multi-condition support existed would
|
||||||
// have) still evaluates correctly via the legacy condition_field/op/value columns.
|
// have) still evaluates correctly via the legacy condition_field/op/value columns.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
|
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
|
||||||
@@ -87,6 +89,57 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestJunkListQuarantinesWithoutRejecting confirms a "junk" allowblock entry (the
|
||||||
|
// self-service webmail Blocklist — distinct from "block", which hard-rejects at RCPT
|
||||||
|
// instead) is still accepted at SMTP level but always lands in Junk, bypassing spam
|
||||||
|
// scoring entirely — the same soft-quarantine outcome as a heuristic/rspamd score hit,
|
||||||
|
// but decided unconditionally rather than computed.
|
||||||
|
func TestJunkListQuarantinesWithoutRejecting(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "junk", "test@example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("expected RCPT accepted (junk quarantines, doesn't reject), got: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: hi\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inbox, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(inbox) != 0 {
|
||||||
|
t.Fatalf("expected nothing in INBOX, found %d", len(inbox))
|
||||||
|
}
|
||||||
|
junk, err := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(junk) != 1 {
|
||||||
|
t.Fatalf("expected 1 message quarantined in Junk, got %d", len(junk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterRuleDeleteDropsMessage(t *testing.T) {
|
func TestFilterRuleDeleteDropsMessage(t *testing.T) {
|
||||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "drop-me", "delete", ""); err != nil {
|
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "drop-me", "delete", ""); err != nil {
|
||||||
@@ -168,6 +221,94 @@ func TestFilterRuleMarkReadSetsSeenFlag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilterRuleForwardKeepCopyFalseDropsLocalCopy confirms a forward rule with
|
||||||
|
// keep_copy=false doesn't also store a local copy — forwarding itself happens
|
||||||
|
// fire-and-forget in a goroutine (see deliverLocally), so this only asserts on the
|
||||||
|
// local-storage decision, not on the (real, DNS-dependent) outbound delivery
|
||||||
|
// succeeding. ".invalid" is an RFC 2606 TLD guaranteed to never resolve, so the
|
||||||
|
// background forward attempt fails fast instead of hanging the test on a timeout.
|
||||||
|
func TestFilterRuleForwardKeepCopyFalseDropsLocalCopy(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "fwd-drop"}}
|
||||||
|
if _, err := backend.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "forward", "elsewhere@forward-test.invalid", `{"keep_copy":false}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: fwd-drop this\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("expected DATA to still report success even though the rule forwards-and-drops, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 0 {
|
||||||
|
t.Fatalf("expected forward with keep_copy=false to skip local storage, found %d messages", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilterRuleForwardKeepCopyTrueStillStoresLocally confirms the default
|
||||||
|
// (keep_copy=true) still stores the mailbox owner's own copy alongside forwarding.
|
||||||
|
func TestFilterRuleForwardKeepCopyTrueStillStoresLocally(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "fwd-keep"}}
|
||||||
|
if _, err := backend.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "forward", "elsewhere@forward-test.invalid", `{"keep_copy":true}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: fwd-keep this\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected forward with keep_copy=true to still store locally, found %d messages", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
|
func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
|
||||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Junk"); err != nil {
|
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Junk"); err != nil {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
"mailgoserver/internal/abuseguard"
|
"mailgoserver/internal/abuseguard"
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
"mailgoserver/internal/dkim"
|
"mailgoserver/internal/dkim"
|
||||||
"mailgoserver/internal/mailstore"
|
"mailgoserver/internal/mailstore"
|
||||||
|
"mailgoserver/internal/mailview"
|
||||||
"mailgoserver/internal/relay"
|
"mailgoserver/internal/relay"
|
||||||
"mailgoserver/internal/toolbox"
|
"mailgoserver/internal/toolbox"
|
||||||
)
|
)
|
||||||
@@ -428,6 +430,21 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
rspamdURL := s.backend.Cfg.Section("Rspamd").Key("url").MustString("http://127.0.0.1:11333")
|
rspamdURL := s.backend.Cfg.Section("Rspamd").Key("url").MustString("http://127.0.0.1:11333")
|
||||||
rspamdRejectScore := s.backend.Cfg.Section("Rspamd").Key("reject_score").MustInt(15)
|
rspamdRejectScore := s.backend.Cfg.Section("Rspamd").Key("reject_score").MustInt(15)
|
||||||
|
|
||||||
|
// Parsed once for every local recipient (not per-recipient — same message body for
|
||||||
|
// all of them) so filter rules can match on body text / attachment presence
|
||||||
|
// without every mailbox needing its own parse pass. Best-effort: a message this
|
||||||
|
// package's own parser can't handle just never matches those two condition types.
|
||||||
|
bodyText, hasAttachment := "", "no"
|
||||||
|
if parsedForRules, err := mailview.Parse([]byte(signedContent)); err == nil {
|
||||||
|
bodyText = parsedForRules.TextBody
|
||||||
|
if bodyText == "" && parsedForRules.HTMLBody != "" {
|
||||||
|
bodyText = bluemonday.StrictPolicy().Sanitize(parsedForRules.HTMLBody)
|
||||||
|
}
|
||||||
|
if len(parsedForRules.Attachments) > 0 {
|
||||||
|
hasAttachment = "yes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]relay.Result, 0, len(rcpts))
|
results := make([]relay.Result, 0, len(rcpts))
|
||||||
for i, rcpt := range rcpts {
|
for i, rcpt := range rcpts {
|
||||||
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
||||||
@@ -439,6 +456,18 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
// other (additive, not either/or), but neither runs at all once allow-listed.
|
// other (additive, not either/or), but neither runs at all once allow-listed.
|
||||||
spamGated := false
|
spamGated := false
|
||||||
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
|
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
|
||||||
|
// The mailbox owner's own Blocklist (webmail Settings, or the message-view
|
||||||
|
// "Mark as Junk" action — internal/webui's webmailMarkAsJunk) also bypasses
|
||||||
|
// scoring entirely, straight to Junk: the user already told us how to
|
||||||
|
// treat this sender, so there's nothing left to compute (and no rspamd
|
||||||
|
// round-trip to make). Deliberately a *soft* quarantine (still delivered,
|
||||||
|
// just hidden), unlike admin's separate hard-reject block list
|
||||||
|
// (IsBlocked, checked at RCPT time — see Rcpt()) — those are different
|
||||||
|
// tools for different jobs, not two ways to do the same thing.
|
||||||
|
if junked, _ := s.backend.DB.IsJunked(mbox.ID, s.mailFrom); junked {
|
||||||
|
folder = "Junk"
|
||||||
|
spamGated = true
|
||||||
|
} else {
|
||||||
quarantine := heuristicScore >= rejectScore
|
quarantine := heuristicScore >= rejectScore
|
||||||
hardReject := false
|
hardReject := false
|
||||||
if rspamdEnabled {
|
if rspamdEnabled {
|
||||||
@@ -467,16 +496,40 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
spamGated = true
|
spamGated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter rules organize legitimate mail the recipient already trusts arriving
|
// Filter rules organize legitimate mail the recipient already trusts arriving
|
||||||
// in their INBOX — a quarantined message skips them entirely and always lands
|
// in their INBOX — a quarantined message skips them entirely and always lands
|
||||||
// in Junk, rather than a rule accidentally routing spam back into view.
|
// in Junk, rather than a rule accidentally routing spam back into view.
|
||||||
if !spamGated {
|
if !spamGated {
|
||||||
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
|
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{
|
||||||
|
"from": s.mailFrom, "to": rcpt, "subject": subject,
|
||||||
|
"body": bodyText, "has_attachment": hasAttachment, "recipient_type": types[i],
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if action.ForwardTo != "" {
|
||||||
|
// Fire-and-forget: forwarding is a side effect layered on top of this
|
||||||
|
// recipient's own local delivery, not a substitute for it — a slow or
|
||||||
|
// unreachable forward target must never delay the SMTP response.
|
||||||
|
// Envelope-from is the mailbox's own address (not the original
|
||||||
|
// sender's) so this doesn't masquerade as a relay of someone else's
|
||||||
|
// mail; no SRS rewriting or Resent-* headers, matching every other
|
||||||
|
// send path in this codebase.
|
||||||
|
forwardTo, mailboxEmail := action.ForwardTo, mbox.Email
|
||||||
|
go func() {
|
||||||
|
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"})
|
||||||
|
if len(res) > 0 && res[0].Status != "success" {
|
||||||
|
s.backend.Logger.Error("forward rule: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !action.KeepCopy {
|
||||||
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if action.Drop {
|
if action.Drop {
|
||||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import (
|
|||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validConditionFields = map[string]bool{"from": true, "to": true, "subject": true}
|
var validConditionFields = map[string]bool{
|
||||||
|
"from": true, "to": true, "subject": true,
|
||||||
|
"body": true, "has_attachment": true, "recipient_type": true,
|
||||||
|
}
|
||||||
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||||
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true}
|
var validActions = map[string]bool{
|
||||||
|
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true,
|
||||||
|
}
|
||||||
|
|
||||||
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
|
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
|
||||||
// arrays (one value per condition row, same index across all three) — shared by the
|
// arrays (one value per condition row, same index across all three) — shared by the
|
||||||
@@ -35,8 +40,15 @@ func parseRuleConditions(r *http.Request) ([]db.RuleCondition, bool) {
|
|||||||
return conditions, true
|
return conditions, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var conditionFieldLabels = map[string]string{
|
||||||
|
"from": "from", "to": "to", "subject": "subject", "body": "body",
|
||||||
|
"has_attachment": "has attachment", "recipient_type": "I'm on the",
|
||||||
|
}
|
||||||
|
|
||||||
// summarizeConditions renders a rule's condition list as a human-readable string for
|
// summarizeConditions renders a rule's condition list as a human-readable string for
|
||||||
// display, e.g. `to contains "sales" AND subject contains "invoice"`.
|
// display, e.g. `to contains "sales" AND subject contains "invoice"`. A "\n"-joined
|
||||||
|
// multi-value condition (the rule builder's chip input) renders as a quoted,
|
||||||
|
// comma-separated list, e.g. `from is any of "a@x.com, b@y.com"`.
|
||||||
func summarizeConditions(r db.MailboxFilterRule) string {
|
func summarizeConditions(r db.MailboxFilterRule) string {
|
||||||
conditions, matchType := r.Conditions()
|
conditions, matchType := r.Conditions()
|
||||||
joiner := " AND "
|
joiner := " AND "
|
||||||
@@ -45,7 +57,25 @@ func summarizeConditions(r db.MailboxFilterRule) string {
|
|||||||
}
|
}
|
||||||
parts := make([]string, len(conditions))
|
parts := make([]string, len(conditions))
|
||||||
for i, c := range conditions {
|
for i, c := range conditions {
|
||||||
parts[i] = c.Field + " " + strings.ReplaceAll(c.Op, "_", " ") + ` "` + c.Value + `"`
|
label := conditionFieldLabels[c.Field]
|
||||||
|
if label == "" {
|
||||||
|
label = c.Field
|
||||||
|
}
|
||||||
|
values := strings.Split(c.Value, "\n")
|
||||||
|
switch c.Field {
|
||||||
|
case "has_attachment":
|
||||||
|
parts[i] = label + ": " + c.Value
|
||||||
|
case "recipient_type":
|
||||||
|
parts[i] = label + " " + strings.ToUpper(c.Value) + " line"
|
||||||
|
case "":
|
||||||
|
parts[i] = ""
|
||||||
|
default:
|
||||||
|
op := strings.ReplaceAll(c.Op, "_", " ")
|
||||||
|
if len(values) > 1 {
|
||||||
|
op = "is any of"
|
||||||
|
}
|
||||||
|
parts[i] = label + " " + op + ` "` + strings.Join(values, ", ") + `"`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strings.Join(parts, joiner)
|
return strings.Join(parts, joiner)
|
||||||
}
|
}
|
||||||
@@ -90,7 +120,7 @@ func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.DB.CreateRuleMulti(mailbox.ID, priority, conditions, matchType, action, actionValue); err != nil {
|
if _, err := a.DB.CreateRuleMulti(mailbox.ID, priority, conditions, matchType, "", action, actionValue, ""); err != nil {
|
||||||
setFlash(w, "error", "Error creating rule")
|
setFlash(w, "error", "Error creating rule")
|
||||||
} else {
|
} else {
|
||||||
setFlash(w, "success", "Rule added")
|
setFlash(w, "success", "Rule added")
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ var standalonePages = []string{
|
|||||||
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
|
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
|
||||||
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
|
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
|
||||||
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
|
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
|
||||||
"webmail_signatures.html",
|
"webmail_signatures.html", "webmail_contacts.html", "webmail_blocklist.html",
|
||||||
// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
|
// A bare HTML fragment (no <html>/base.html chrome at all), fetched via JS and
|
||||||
// injected into logs.html's full-screen modal — not a page anyone navigates to
|
// injected into logs.html's full-screen modal — not a page anyone navigates to
|
||||||
// directly, so it doesn't need to look like a standalone document the way the
|
// directly, so it doesn't need to look like a standalone document the way the
|
||||||
@@ -188,7 +188,7 @@ var standalonePages = []string{
|
|||||||
// something that opens a popup of its own.
|
// something that opens a popup of its own.
|
||||||
var pagesWithComposeWidget = []string{
|
var pagesWithComposeWidget = []string{
|
||||||
"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
|
"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
|
||||||
"webmail_signatures.html",
|
"webmail_signatures.html", "webmail_contacts.html", "webmail_blocklist.html",
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasComposeWidget(page string) bool {
|
func hasComposeWidget(page string) bool {
|
||||||
@@ -200,6 +200,22 @@ func hasComposeWidget(page string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pagesWithSettingsChrome are the settings sections consolidated into one
|
||||||
|
// Outlook-style left-nav/right-content layout — see webmail_settings_chrome.html.
|
||||||
|
var pagesWithSettingsChrome = []string{
|
||||||
|
"webmail_account.html", "webmail_rules.html", "webmail_signatures.html", "webmail_certs.html",
|
||||||
|
"webmail_contacts.html", "webmail_blocklist.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSettingsChrome(page string) bool {
|
||||||
|
for _, p := range pagesWithSettingsChrome {
|
||||||
|
if p == page {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the
|
// pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the
|
||||||
// message list (j/k/Enter/o) and a single open message (r/a/f/#). See
|
// message list (j/k/Enter/o) and a single open message (r/a/f/#). See
|
||||||
// webmail_shortcuts.html's {{define "webmail_shortcuts"}}.
|
// webmail_shortcuts.html's {{define "webmail_shortcuts"}}.
|
||||||
@@ -233,6 +249,9 @@ func (a *App) loadTemplates() error {
|
|||||||
if hasComposeWidget(page) {
|
if hasComposeWidget(page) {
|
||||||
files = append(files, "templates/webmail_compose_widget.html")
|
files = append(files, "templates/webmail_compose_widget.html")
|
||||||
}
|
}
|
||||||
|
if hasSettingsChrome(page) {
|
||||||
|
files = append(files, "templates/webmail_settings_chrome.html")
|
||||||
|
}
|
||||||
if hasShortcuts(page) {
|
if hasShortcuts(page) {
|
||||||
files = append(files, "templates/webmail_shortcuts.html")
|
files = append(files, "templates/webmail_shortcuts.html")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
var token = window.__csrfToken;
|
var token = window.__csrfToken;
|
||||||
if (!token) return; // no session cookie yet (e.g. the login page itself)
|
if (!token) return; // no session cookie yet (e.g. the login page itself)
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// Exposed so content injected later (e.g. an AJAX-swapped settings section,
|
||||||
document.querySelectorAll('form').forEach(function(form) {
|
// see webmail_settings_chrome.html) can get the same treatment — forms
|
||||||
|
// added after DOMContentLoaded already fired would otherwise submit with no
|
||||||
|
// token and get rejected by the server-side check.
|
||||||
|
window.__applyCsrfToForms = function(root) {
|
||||||
|
(root || document).querySelectorAll('form').forEach(function(form) {
|
||||||
if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return;
|
if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return;
|
||||||
if (form.querySelector('input[name="csrf_token"]')) return;
|
if (form.querySelector('input[name="csrf_token"]')) return;
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
@@ -19,7 +23,8 @@
|
|||||||
input.value = token;
|
input.value = token;
|
||||||
form.appendChild(input);
|
form.appendChild(input);
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
document.addEventListener('DOMContentLoaded', function() { window.__applyCsrfToForms(document); });
|
||||||
|
|
||||||
var mutating = { POST: true, PUT: true, PATCH: true, DELETE: true };
|
var mutating = { POST: true, PUT: true, PATCH: true, DELETE: true };
|
||||||
var originalFetch = window.fetch.bind(window);
|
var originalFetch = window.fetch.bind(window);
|
||||||
|
|||||||
@@ -4,26 +4,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.mailbox.Email}} - Webmail</title>
|
<title>Settings - Webmail</title>
|
||||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
|
||||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
</style>
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "csrf_script" .}}
|
{{template "csrf_script" .}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -45,11 +43,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container pb-5">
|
<div id="settingsPanelRoot">
|
||||||
<div class="row">
|
<div class="settings-card" id="settingsPanel">
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="settings-card-header">
|
||||||
<div class="card">
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd me-2"></i>Storage</h5></div>
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-hdd me-2"></i>Storage</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="progress mb-2" style="height: 1.25rem;">
|
<div class="progress mb-2" style="height: 1.25rem;">
|
||||||
<div class="progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style="width: {{printf "%.0f" .pct_full}}%">{{printf "%.0f" .pct_full}}%</div>
|
<div class="progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style="width: {{printf "%.0f" .pct_full}}%">{{printf "%.0f" .pct_full}}%</div>
|
||||||
@@ -58,8 +62,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Preferences</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-sliders me-2"></i>Preferences</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="/webmail/account/preferences">
|
<form method="POST" action="/webmail/account/preferences">
|
||||||
<label class="form-label">Group similar subjects in the message list</label>
|
<label class="form-label">Group similar subjects in the message list</label>
|
||||||
@@ -78,8 +82,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-image me-2"></i>Remote Images</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-image me-2"></i>Remote Images</h6></div>
|
||||||
<div class="card-body">
|
<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>
|
<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">
|
<form method="POST" action="/webmail/account/remote-images-mode">
|
||||||
@@ -124,8 +128,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mb-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-header"><h6 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="/webmail/account/password">
|
<form method="POST" action="/webmail/account/password">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -146,8 +150,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Two-Factor Authentication</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Two-Factor Authentication</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6>Authenticator App</h6>
|
<h6>Authenticator App</h6>
|
||||||
{{if .mailbox.TOTPEnabled}}
|
{{if .mailbox.TOTPEnabled}}
|
||||||
@@ -182,11 +186,9 @@
|
|||||||
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
|
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6 mb-4">
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>App Passwords</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>App Passwords</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
|
<i class="bi bi-info-circle me-2"></i>Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
|
||||||
@@ -227,6 +229,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -247,103 +250,8 @@
|
|||||||
{{template "compose_widget" .}}
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
const TOAST_AUTOHIDE_MS = 5000;
|
{{template "webmail_settings_script" .}}
|
||||||
function armToastAutoDismiss(toastEl, bsToast) {
|
|
||||||
let timer = null;
|
|
||||||
const start = () => { timer = setTimeout(() => bsToast.hide(), TOAST_AUTOHIDE_MS); };
|
|
||||||
const stop = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
||||||
toastEl.addEventListener('mouseenter', stop);
|
|
||||||
toastEl.addEventListener('mouseleave', start);
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('.toast').forEach(function(el) {
|
|
||||||
const toast = new bootstrap.Toast(el);
|
|
||||||
toast.show();
|
|
||||||
armToastAutoDismiss(el, toast);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function showConfirmation(message) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const modal = document.getElementById('confirmationModal');
|
|
||||||
document.getElementById('confirmationModalBody').textContent = message;
|
|
||||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
|
||||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
|
||||||
const handleCancel = () => { resolve(false); cleanup(); };
|
|
||||||
const cleanup = () => {
|
|
||||||
confirmButton.removeEventListener('click', handleConfirm);
|
|
||||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
|
||||||
};
|
|
||||||
confirmButton.addEventListener('click', handleConfirm);
|
|
||||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
|
||||||
new bootstrap.Modal(modal).show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
|
||||||
const form = this.closest('form');
|
|
||||||
if (form) form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function b64urlToBuf(s) {
|
|
||||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
while (s.length % 4) s += '=';
|
|
||||||
const bin = atob(s);
|
|
||||||
const buf = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
||||||
return buf.buffer;
|
|
||||||
}
|
|
||||||
function bufToB64url(buf) {
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let bin = '';
|
|
||||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
|
||||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
const passkeyAddBtn = document.getElementById('passkey-add-btn');
|
|
||||||
if (passkeyAddBtn) {
|
|
||||||
passkeyAddBtn.addEventListener('click', async function() {
|
|
||||||
const errEl = document.getElementById('passkey-error');
|
|
||||||
errEl.classList.add('d-none');
|
|
||||||
try {
|
|
||||||
const beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
|
|
||||||
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
|
||||||
const options = await beginResp.json();
|
|
||||||
const publicKey = options.publicKey;
|
|
||||||
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
|
||||||
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
|
||||||
if (publicKey.excludeCredentials) {
|
|
||||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
|
||||||
}
|
|
||||||
const cred = await navigator.credentials.create({ publicKey });
|
|
||||||
const body = {
|
|
||||||
id: cred.id,
|
|
||||||
rawId: bufToB64url(cred.rawId),
|
|
||||||
type: cred.type,
|
|
||||||
response: {
|
|
||||||
attestationObject: bufToB64url(cred.response.attestationObject),
|
|
||||||
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const finishResp = await fetch('/webmail/account/passkey/finish', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
|
||||||
window.location.reload();
|
|
||||||
} catch (e) {
|
|
||||||
errEl.textContent = e.message || 'Passkey registration failed';
|
|
||||||
errEl.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{{define "webmail_blocklist.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Settings - Webmail</title>
|
||||||
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "csrf_script" .}}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||||
|
{{range .flashes}}
|
||||||
|
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||||
|
{{.Message}}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsPanelRoot">
|
||||||
|
<div class="settings-card" id="settingsPanel">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>An address or a whole domain (<code>@example.com</code>). <strong>Block</strong> quarantines matching mail straight to Junk without scoring it — the same action as right-click > Mark as Junk. <strong>Whitelist</strong> skips spam scoring entirely, always landing in INBOX — use it for a trusted sender rspamd or the built-in filter keeps flagging.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add entry</h6></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/webmail/blocklist/add" class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Address or @domain.com</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="pattern" placeholder="sender@example.com or @example.com" required style="min-width: 260px;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">List</label>
|
||||||
|
<select class="form-select form-select-sm" name="list_type">
|
||||||
|
<option value="junk">Block (→ Junk)</option>
|
||||||
|
<option value="allow">Whitelist (never spam)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-lg me-1"></i>Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-slash-circle me-2"></i>Blocked <small class="text-muted fs-6">— goes to Junk</small></h6></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{{if .blocked}}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{{range .blocked}}
|
||||||
|
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||||
|
<code>{{.Pattern}}</code>
|
||||||
|
<form method="post" action="/webmail/blocklist/{{.ID}}/remove" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this entry?"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted p-3 mb-0">Nothing blocked yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-check-circle me-2"></i>Whitelisted <small class="text-muted fs-6">— never spam</small></h6></div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{{if .allowed}}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{{range .allowed}}
|
||||||
|
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
|
||||||
|
<code>{{.Pattern}}</code>
|
||||||
|
<form method="post" action="/webmail/blocklist/{{.ID}}/remove" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this entry?"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted p-3 mb-0">Nothing whitelisted yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
|
{{template "webmail_settings_script" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -4,27 +4,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Certs - Webmail</title>
|
<title>Settings - Webmail</title>
|
||||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
|
||||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
</style>
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "csrf_script" .}}
|
{{template "csrf_script" .}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
|
||||||
<a href="/webmail/certs" class="btn btn-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -46,20 +43,26 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container pb-5">
|
<div id="settingsPanelRoot">
|
||||||
<h4 class="mb-4"><i class="bi bi-shield-lock me-2"></i>Certs</h4>
|
<div class="settings-card" id="settingsPanel">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>Two separate systems live here, each doing one job: <strong>S/MIME certificates sign</strong> outgoing mail (proves it came from you and wasn't altered) — <strong>PGP keys encrypt</strong> it (only the recipient can read it). They're different standards with different key formats; a message can use either, both, or neither. PGP private keys are protected by their own passphrase (never stored anywhere), so you'll be asked for it the first time you use one each session.
|
<i class="bi bi-info-circle me-2"></i>Two separate systems live here, each doing one job: <strong>S/MIME certificates sign</strong> outgoing mail (proves it came from you and wasn't altered) — <strong>PGP keys encrypt</strong> it (only the recipient can read it). They're different standards with different key formats; a message can use either, both, or neither. PGP private keys are protected by their own passphrase (never stored anywhere), so you'll be asked for it the first time you use one each session.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 class="mb-3"><i class="bi bi-pen me-2"></i>S/MIME Certificates <small class="text-muted fs-6">— for signing</small></h5>
|
<h6 class="text-uppercase text-muted mb-2"><i class="bi bi-pen me-1"></i>S/MIME Certificates <small>— for signing</small></h6>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-person-badge me-2"></i>Your Certificates</h6></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-person-badge me-2"></i>Your Certificates</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{if .identities}}
|
{{if .identities}}
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-3">
|
||||||
<table class="table table-dark table-hover mb-0">
|
<table class="table table-dark table-hover mb-0">
|
||||||
<thead><tr><th>Expires</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Expires</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
<p class="text-muted">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have (.p12/.pfx).</p>
|
<p class="text-muted">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have (.p12/.pfx).</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-3">
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -110,10 +113,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-5">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>S/MIME Contact Certificates</h6></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>S/MIME Contact Certificates</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/webmail/smime/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
|
<form method="post" action="/webmail/smime/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-3">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
||||||
@@ -152,13 +155,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 class="mb-3"><i class="bi bi-lock me-2"></i>PGP Keys <small class="text-muted fs-6">— for encryption</small></h5>
|
<h6 class="text-uppercase text-muted mb-2"><i class="bi bi-lock me-1"></i>PGP Keys <small>— for encryption</small></h6>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>Your Keys</h6></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>Your Keys</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{if .pgp_identities}}
|
{{if .pgp_identities}}
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-3">
|
||||||
<table class="table table-dark table-hover mb-0">
|
<table class="table table-dark table-hover mb-0">
|
||||||
<thead><tr><th>Label</th><th>Fingerprint</th><th>Status</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Label</th><th>Fingerprint</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -182,7 +185,7 @@
|
|||||||
<p class="text-muted">No PGP keys yet. Generate a new keypair, or import one you already have (an ASCII-armored .asc export from e.g. GnuPG).</p>
|
<p class="text-muted">No PGP keys yet. Generate a new keypair, or import one you already have (an ASCII-armored .asc export from e.g. GnuPG).</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-3">
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -226,7 +229,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>PGP Contact Keys</h6></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>PGP Contact Keys</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="/webmail/pgp/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
|
<form method="post" action="/webmail/pgp/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-3">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
||||||
@@ -271,6 +274,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -291,38 +297,8 @@
|
|||||||
{{template "compose_widget" .}}
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
{{template "webmail_settings_script" .}}
|
||||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
|
||||||
});
|
|
||||||
function showConfirmation(message) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const modal = document.getElementById('confirmationModal');
|
|
||||||
document.getElementById('confirmationModalBody').textContent = message;
|
|
||||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
|
||||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
|
||||||
const handleCancel = () => { resolve(false); cleanup(); };
|
|
||||||
const cleanup = () => {
|
|
||||||
confirmButton.removeEventListener('click', handleConfirm);
|
|
||||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
|
||||||
};
|
|
||||||
confirmButton.addEventListener('click', handleConfirm);
|
|
||||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
|
||||||
new bootstrap.Modal(modal).show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
|
||||||
const form = this.closest('form');
|
|
||||||
if (form) form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
</select>
|
</select>
|
||||||
{{range .signatures}}<div class="sig-data" data-sig-id="{{.ID}}" style="display: none;">{{.ContentHTML | safe}}</div>{{end}}
|
{{range .signatures}}<div class="sig-data" data-sig-id="{{.ID}}" style="display: none;">{{.ContentHTML | safe}}</div>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<script type="application/json" id="signatureDefaultsData">{{.signature_defaults_json}}</script>
|
||||||
|
<script>var composeForReply = {{.compose_for_reply}};</script>
|
||||||
<div class="ms-auto d-flex align-items-center gap-2">
|
<div class="ms-auto d-flex align-items-center gap-2">
|
||||||
{{if .send_as_options}}
|
{{if .send_as_options}}
|
||||||
<select class="form-select form-select-sm from-select" name="from">
|
<select class="form-select form-select-sm from-select" name="from">
|
||||||
@@ -243,6 +245,26 @@
|
|||||||
if (!select.value) return;
|
if (!select.value) return;
|
||||||
sigRange = insertSignature(insertAt, sigHTML[select.value] || '');
|
sigRange = insertSignature(insertAt, sigHTML[select.value] || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Switching "From" (a send-as alias) re-picks whichever signature is that
|
||||||
|
// address's own default — see composeFormData's signature_defaults_json.
|
||||||
|
// Only ever auto-*switches* to a different default; it never removes a
|
||||||
|
// signature the user picked by hand if that address has none of its own
|
||||||
|
// (falls back to "leave whatever's already inserted" instead of clearing it).
|
||||||
|
const fromSelect = document.querySelector('.from-select');
|
||||||
|
const defaultsDataEl = document.getElementById('signatureDefaultsData');
|
||||||
|
if (fromSelect && defaultsDataEl) {
|
||||||
|
let signatureDefaults = {};
|
||||||
|
try { signatureDefaults = JSON.parse(defaultsDataEl.textContent) || {}; } catch (e) { signatureDefaults = {}; }
|
||||||
|
fromSelect.addEventListener('change', function() {
|
||||||
|
const entry = signatureDefaults[fromSelect.value];
|
||||||
|
if (!entry) return;
|
||||||
|
const newId = composeForReply ? entry.reply : entry.new;
|
||||||
|
if (!newId) return;
|
||||||
|
select.value = String(newId);
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
|
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
{{define "webmail_contacts.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Settings - Webmail</title>
|
||||||
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "csrf_script" .}}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||||
|
{{range .flashes}}
|
||||||
|
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||||
|
{{.Message}}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsPanelRoot">
|
||||||
|
<div class="settings-card" id="settingsPanel">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0">Contacts</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="contactAddBtn"><i class="bi bi-plus-lg me-1"></i>Add contact</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .contacts}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>Email</th><th>Phone</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .contacts}}
|
||||||
|
<tr class="contact-row" data-contact-id="{{.ID}}" data-contact-name="{{.Name}}" data-contact-email="{{.Email}}" data-contact-phone="{{.Phone}}">
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{if .Phone}}{{.Phone}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||||
|
<td class="d-flex gap-1 justify-content-end">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm contact-edit-btn" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
|
<form method="post" action="/webmail/contacts/{{.ID}}/delete" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete" data-confirm="Delete contact "{{.Name}}"?"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted mb-0">No contacts yet — add one to have it available in compose autocomplete.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="contactModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="/webmail/contacts/save" id="contactForm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="contactModalTitle">New contact</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="id" id="contactFormId" value="">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="contactFormName" placeholder="Jane Doe" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" name="email" id="contactFormEmail" placeholder="jane@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Phone <span class="text-muted">(optional)</span></label>
|
||||||
|
<input type="text" class="form-control" name="phone" id="contactFormPhone" placeholder="+1 555 123 4567">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
|
{{template "webmail_settings_script" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -88,10 +88,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
|
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Settings"><i class="bi bi-gear"></i></a>
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm" title="Signatures"><i class="bi bi-pen"></i></a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
|
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -24,10 +24,7 @@
|
|||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Settings</a>
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,27 +4,32 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Filter Rules - Webmail</title>
|
<title>Settings - Webmail</title>
|
||||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
|
||||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
</style>
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
|
<style>
|
||||||
|
.condition-row { background-color: #262626; }
|
||||||
|
.chip-input { min-height: calc(1.75em + .5rem + 2px); background-color: #1a1a1a; border-color: #404040; cursor: text; }
|
||||||
|
.chip-input input { background: transparent; color: #e0e0e0; }
|
||||||
|
.chip-item { font-weight: normal; }
|
||||||
|
.chip-item .bi-x { cursor: pointer; }
|
||||||
|
.action-extra-fields > div { display: none; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "csrf_script" .}}
|
{{template "csrf_script" .}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -46,103 +51,149 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container pb-5">
|
<div id="settingsPanelRoot">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="settings-card" id="settingsPanel">
|
||||||
<h4 class="mb-0"><i class="bi bi-funnel me-2"></i>Filter Rules</h4>
|
<div class="settings-card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins. "Move to folder" delivers into a separate folder instead of INBOX — check <a href="/webmail/mail/INBOX" class="alert-link">Mail</a> once something's actually landed there.
|
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-{{if .editing}}pencil{{else}}plus-circle{{end}} me-2"></i>{{if .editing}}Edit Rule{{else}}Add Rule{{end}}</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="/webmail/rules/add">
|
<form method="POST" action="/webmail/rules/save" id="ruleForm">
|
||||||
<div class="row g-2 align-items-end mb-3">
|
<input type="hidden" name="rule_id" value="{{if .editing}}{{.editing.ID}}{{end}}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rule name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="name" value="{{if .editing}}{{.editing.Name}}{{end}}" placeholder="e.g. Invoices to Accounting" style="max-width: 360px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 align-items-end mb-2">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Priority</label>
|
<label class="form-label">Priority</label>
|
||||||
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
|
<input type="number" class="form-control form-control-sm" name="priority" value="{{if .editing}}{{.editing.Priority}}{{else}}0{{end}}" style="width: 90px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Match</label>
|
<label class="form-label">Match</label>
|
||||||
<select class="form-select" name="match_type">
|
<select class="form-select form-select-sm" name="match_type" id="rule_match_type">
|
||||||
<option value="all">ALL of the following (AND)</option>
|
<option value="all" {{if ne .editing_match_type "any"}}selected{{end}}>ALL of the following (AND)</option>
|
||||||
<option value="any">ANY of the following (OR)</option>
|
<option value="any" {{if eq .editing_match_type "any"}}selected{{end}}>ANY of the following (OR)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .editing}}
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="rule_is_active" value="1" {{if .editing.IsActive}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="rule_is_active">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="conditions_container"></div>
|
<div id="conditions_container"></div>
|
||||||
<button type="button" id="add_condition" class="btn btn-outline-light btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
|
<button type="button" id="add_condition" class="btn btn-outline-light btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Then</label>
|
<label class="form-label">Then</label>
|
||||||
<select class="form-select" name="action" id="rule_action">
|
<select class="form-select form-select-sm" name="action" id="rule_action">
|
||||||
<option value="move_to_folder">Move to folder</option>
|
<option value="move_to_folder" {{if or (not .editing) (eq .editing.Action "move_to_folder")}}selected{{end}}>Move to folder</option>
|
||||||
<option value="mark_as_spam">Mark as Junk</option>
|
<option value="mark_as_spam" {{if and .editing (eq .editing.Action "mark_as_spam")}}selected{{end}}>Mark as Junk</option>
|
||||||
<option value="delete">Delete</option>
|
<option value="delete" {{if and .editing (eq .editing.Action "delete")}}selected{{end}}>Delete</option>
|
||||||
<option value="mark_read">Mark as read</option>
|
<option value="mark_read" {{if and .editing (eq .editing.Action "mark_read")}}selected{{end}}>Mark as read</option>
|
||||||
|
<option value="forward" {{if and .editing (eq .editing.Action "forward")}}selected{{end}}>Forward to...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto action-extra-fields">
|
||||||
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
|
<div class="action-field-move_to_folder">
|
||||||
|
<label class="form-label">Folder name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="action_value" id="rule_action_value_folder" placeholder="folder name" value="{{if and .editing (eq .editing.Action "move_to_folder")}}{{.editing.ActionValue}}{{end}}">
|
||||||
|
</div>
|
||||||
|
<div class="action-field-forward">
|
||||||
|
<label class="form-label">Forward to</label>
|
||||||
|
<input type="email" class="form-control form-control-sm" name="action_value" id="rule_action_value_forward" disabled placeholder="someone@example.com" value="{{if and .editing (eq .editing.Action "forward")}}{{.editing.ActionValue}}{{end}}">
|
||||||
|
<div class="form-check mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="keep_copy" id="rule_keep_copy" value="1" {{if or (not .editing) (ne .editing.Action "forward") .editing.ActionOptions.KeepCopy}}checked{{end}}>
|
||||||
|
<label class="form-check-label small" for="rule_keep_copy">Also keep a copy in this mailbox</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
|
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>{{if .editing}}Update Rule{{else}}Add Rule{{end}}</button>
|
||||||
|
{{if .editing}}<a href="/webmail/rules" class="btn btn-outline-secondary">Cancel</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template id="condition_row_template">
|
<template id="condition_row_template">
|
||||||
<div class="row g-2 align-items-end mb-2 condition-row">
|
<div class="row g-2 align-items-start mb-2 condition-row border rounded p-2" style="border-color: #404040 !important;">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">If</label>
|
<select class="form-select form-select-sm cond-field" name="condition_field" style="width: 150px;">
|
||||||
<select class="form-select" name="condition_field">
|
|
||||||
<option value="from">From</option>
|
<option value="from">From</option>
|
||||||
<option value="to">To</option>
|
<option value="to">To</option>
|
||||||
<option value="subject">Subject</option>
|
<option value="subject">Subject</option>
|
||||||
|
<option value="body">Body</option>
|
||||||
|
<option value="has_attachment">Has attachment</option>
|
||||||
|
<option value="recipient_type">I'm on the</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto cond-op-wrap" style="width: 140px;">
|
||||||
<select class="form-select" name="condition_op">
|
<select class="form-select form-select-sm cond-op" name="condition_op">
|
||||||
<option value="contains">contains</option>
|
<option value="contains">contains</option>
|
||||||
<option value="equals">equals</option>
|
<option value="equals">equals</option>
|
||||||
<option value="starts_with">starts with</option>
|
<option value="starts_with">starts with</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col cond-value-area" style="min-width: 220px;">
|
||||||
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
|
<div class="chip-input form-control form-control-sm d-flex flex-wrap gap-1"></div>
|
||||||
|
<select class="form-select form-select-sm cond-fixed-select d-none"></select>
|
||||||
|
<input type="hidden" class="cond-value-hidden" name="condition_value">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
|
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<script type="application/json" id="editingConditionsData">{{.editing_conditions_json}}</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h5></div>
|
<div class="card-header"><h6 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h6></div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{{if .rules}}
|
{{if .rules}}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-dark table-hover mb-0">
|
<table class="table table-dark table-hover mb-0">
|
||||||
<thead><tr><th>Priority</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Priority</th><th>Name</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .rules}}
|
{{range .rules}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Priority}}</td>
|
<td>{{.Priority}}</td>
|
||||||
<td><code>{{ruleSummary .}}</code></td>
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="text-muted">(unnamed)</span>{{end}}</td>
|
||||||
|
<td><code class="small">{{ruleSummary .}}</code></td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
|
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
|
||||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||||
|
{{else if eq .Action "forward"}}Forward to <strong>{{.ActionValue}}</strong>{{if not .ActionOptions.KeepCopy}} <span class="text-muted">(no local copy)</span>{{end}}
|
||||||
{{else}}Mark as read{{end}}
|
{{else}}Mark as read{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-secondary">Inactive</span>{{end}}</td>
|
|
||||||
<td>
|
<td>
|
||||||
|
<form method="post" action="/webmail/rules/{{.ID}}/toggle" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-sm {{if .IsActive}}btn-success{{else}}btn-secondary{{end}}" title="Click to {{if .IsActive}}disable{{else}}enable{{end}}">{{if .IsActive}}Active{{else}}Inactive{{end}}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<a href="/webmail/rules?edit={{.ID}}" class="btn btn-outline-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<form method="post" action="/webmail/rules/{{.ID}}/remove" class="d-inline">
|
<form method="post" action="/webmail/rules/{{.ID}}/remove" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
|
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
@@ -154,14 +205,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-funnel text-muted" style="font-size: 4rem;"></i>
|
<i class="bi bi-funnel text-muted" style="font-size: 3rem;"></i>
|
||||||
<h4 class="text-muted mt-3">No rules yet</h4>
|
<h6 class="text-muted mt-3">No rules yet</h6>
|
||||||
<p class="text-muted">Add one above to automatically sort or act on incoming mail.</p>
|
<p class="text-muted small">Add one above to automatically sort or act on incoming mail.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -182,56 +236,8 @@
|
|||||||
{{template "compose_widget" .}}
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
{{template "webmail_settings_script" .}}
|
||||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
|
||||||
});
|
|
||||||
function showConfirmation(message) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const modal = document.getElementById('confirmationModal');
|
|
||||||
document.getElementById('confirmationModalBody').textContent = message;
|
|
||||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
|
||||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
|
||||||
const handleCancel = () => { resolve(false); cleanup(); };
|
|
||||||
const cleanup = () => {
|
|
||||||
confirmButton.removeEventListener('click', handleConfirm);
|
|
||||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
|
||||||
};
|
|
||||||
confirmButton.addEventListener('click', handleConfirm);
|
|
||||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
|
||||||
new bootstrap.Modal(modal).show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
|
||||||
const form = this.closest('form');
|
|
||||||
if (form) form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.getElementById('rule_action').addEventListener('change', function(e) {
|
|
||||||
const valueInput = document.getElementById('rule_action_value');
|
|
||||||
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
function addConditionRow() {
|
|
||||||
const tpl = document.getElementById('condition_row_template');
|
|
||||||
const container = document.getElementById('conditions_container');
|
|
||||||
const clone = document.importNode(tpl.content, true);
|
|
||||||
clone.querySelector('.remove-condition').addEventListener('click', function() {
|
|
||||||
if (container.children.length > 1) {
|
|
||||||
this.closest('.condition-row').remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
container.appendChild(clone);
|
|
||||||
}
|
|
||||||
document.getElementById('add_condition').addEventListener('click', addConditionRow);
|
|
||||||
addConditionRow();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
{{define "webmail_settings_style"}}
|
||||||
|
<style>
|
||||||
|
.settings-card { width: 96vw; max-width: 1600px; height: 92vh; margin: 4vh auto; background-color: #2d2d2d; border: 1px solid #404040; border-radius: 12px; box-shadow: 0 1rem 3rem rgba(0,0,0,.5); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.settings-card-header { flex: 0 0 auto; padding: 1rem 1.25rem; border-bottom: 1px solid #404040; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.settings-card-body { flex: 1 1 auto; display: flex; align-items: stretch; min-height: 0; }
|
||||||
|
.settings-nav { width: 210px; flex: 0 0 210px; border-right: 1px solid #404040; padding: .75rem; overflow-y: auto; }
|
||||||
|
.settings-nav .nav-link { color: #c8c8c8; border-radius: 6px; padding: .5rem .75rem; margin-bottom: .15rem; font-size: .9rem; }
|
||||||
|
.settings-nav .nav-link:hover { background-color: #383838; color: #fff; }
|
||||||
|
.settings-nav .nav-link.active { background-color: #0d6efd; color: #fff; }
|
||||||
|
.settings-body { flex: 1 1 auto; padding: 1.25rem 1.5rem; overflow-y: auto; min-width: 0; }
|
||||||
|
.settings-body h5, .settings-body h6 { font-size: .95rem; }
|
||||||
|
.settings-body .card { background-color: #262626; border: 1px solid #404040; }
|
||||||
|
.settings-body .card-header { padding: .5rem .9rem; }
|
||||||
|
.settings-body .card-body { padding: .75rem .9rem; }
|
||||||
|
.settings-body .mb-4 { margin-bottom: .9rem !important; }
|
||||||
|
.settings-body .mb-3 { margin-bottom: .6rem !important; }
|
||||||
|
.sig-preview { background-color: #fff; color: #000; border-radius: 6px; padding: .75rem; max-height: 140px; overflow: hidden; }
|
||||||
|
#sigEditor { height: 160px; background-color: #fff; color: #000; }
|
||||||
|
.ql-toolbar.ql-snow { background-color: #333; border-color: #404040; border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
|
||||||
|
.ql-container.ql-snow { border-color: #404040; border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
|
||||||
|
.ql-snow .ql-stroke { stroke: #c8c8c8; }
|
||||||
|
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
|
||||||
|
.ql-snow .ql-picker { color: #c8c8c8; }
|
||||||
|
.ql-snow .ql-picker-options { background-color: #2d2d2d; border-color: #404040; }
|
||||||
|
.ql-snow .ql-picker-item { color: #c8c8c8; }
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "webmail_settings_nav"}}
|
||||||
|
<div class="settings-nav">
|
||||||
|
<div class="nav flex-column nav-pills" id="settingsNav">
|
||||||
|
<a class="nav-link{{if eq .active_section "account"}} active{{end}}" href="/webmail/account" data-settings-nav><i class="bi bi-gear me-2"></i>Account</a>
|
||||||
|
<a class="nav-link{{if eq .active_section "rules"}} active{{end}}" href="/webmail/rules" data-settings-nav><i class="bi bi-funnel me-2"></i>Rules</a>
|
||||||
|
<a class="nav-link{{if eq .active_section "signatures"}} active{{end}}" href="/webmail/signatures" data-settings-nav><i class="bi bi-pen me-2"></i>Signatures</a>
|
||||||
|
<a class="nav-link{{if eq .active_section "contacts"}} active{{end}}" href="/webmail/contacts" data-settings-nav><i class="bi bi-person-lines-fill me-2"></i>Contacts</a>
|
||||||
|
<a class="nav-link{{if eq .active_section "blocklist"}} active{{end}}" href="/webmail/blocklist" data-settings-nav><i class="bi bi-slash-circle me-2"></i>Blocklist</a>
|
||||||
|
<a class="nav-link{{if eq .active_section "certs"}} active{{end}}" href="/webmail/certs" data-settings-nav><i class="bi bi-shield-lock me-2"></i>Certificates</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "webmail_settings_script"}}
|
||||||
|
<script>
|
||||||
|
function closeSettings() { window.location = '/webmail/mail/INBOX'; }
|
||||||
|
|
||||||
|
// --- Section switching -------------------------------------------------
|
||||||
|
// Each nav link is a real <a href> (direct links / reload / no-JS all still
|
||||||
|
// work — the destination is a normal server-rendered page), but a click
|
||||||
|
// instead fetches that page and swaps in just its #settingsPanel (nav+body
|
||||||
|
// together, so the destination page's own active-state highlighting comes
|
||||||
|
// along for free) rather than navigating away. Delegated on
|
||||||
|
// #settingsPanelRoot so it keeps working after the panel itself gets
|
||||||
|
// replaced.
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var root = document.getElementById('settingsPanelRoot');
|
||||||
|
if (!root) return;
|
||||||
|
root.addEventListener('click', function(e) {
|
||||||
|
var link = e.target.closest('[data-settings-nav]');
|
||||||
|
if (!link) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (link.classList.contains('active')) return;
|
||||||
|
fetch(link.href)
|
||||||
|
.then(function(resp) { return resp.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
var panel = doc.getElementById('settingsPanel');
|
||||||
|
if (!panel) { window.location = link.href; return; }
|
||||||
|
root.innerHTML = panel.outerHTML;
|
||||||
|
window.history.pushState(null, '', link.href);
|
||||||
|
window.__applyCsrfToForms && window.__applyCsrfToForms(root);
|
||||||
|
initSettingsSection();
|
||||||
|
})
|
||||||
|
.catch(function() { window.location = link.href; });
|
||||||
|
});
|
||||||
|
window.addEventListener('popstate', function() { window.location.reload(); });
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSettings(); });
|
||||||
|
|
||||||
|
// --- Toasts + confirm dialogs (delegated — survive section swaps) ------
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||||
|
});
|
||||||
|
function showConfirmation(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
var modal = document.getElementById('confirmationModal');
|
||||||
|
document.getElementById('confirmationModalBody').textContent = message;
|
||||||
|
var confirmButton = document.getElementById('confirmationModalConfirm');
|
||||||
|
var handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||||
|
var handleCancel = () => { resolve(false); cleanup(); };
|
||||||
|
var cleanup = () => {
|
||||||
|
confirmButton.removeEventListener('click', handleConfirm);
|
||||||
|
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||||
|
};
|
||||||
|
confirmButton.addEventListener('click', handleConfirm);
|
||||||
|
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||||
|
new bootstrap.Modal(modal).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var button = e.target.closest('[data-confirm]');
|
||||||
|
if (!button) return;
|
||||||
|
e.preventDefault();
|
||||||
|
showConfirmation(button.getAttribute('data-confirm')).then(function(ok) {
|
||||||
|
if (ok) { var form = button.closest('form'); if (form) form.submit(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Section-specific init, re-run after every swap ---------------------
|
||||||
|
function initSettingsSection() {
|
||||||
|
initRulesSection();
|
||||||
|
initSignaturesSection();
|
||||||
|
initAccountSection();
|
||||||
|
initContactsSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed-choice condition fields don't take free text — has_attachment is a
|
||||||
|
// yes/no question, recipient_type mirrors Outlook's "I'm on the Cc line" (this
|
||||||
|
// delivery's own to/cc/bcc-ness, computed server-side per recipient — see
|
||||||
|
// smtpserver's deliverLocally). Both always match via "equals".
|
||||||
|
var RULE_FIXED_FIELD_OPTIONS = {
|
||||||
|
has_attachment: [['yes', 'Yes'], ['no', 'No']],
|
||||||
|
recipient_type: [['to', 'To'], ['cc', 'Cc'], ['bcc', 'Bcc']],
|
||||||
|
};
|
||||||
|
|
||||||
|
// A minimal tag/chip input: type a value, press Enter or comma to commit it as a
|
||||||
|
// chip. Several chips in one condition mean "matches any of these" (an OR within
|
||||||
|
// that single condition) — see mailstore.matchCondition, which reads the "\n"-
|
||||||
|
// joined hidden value this maintains.
|
||||||
|
function createChipInput(container, hiddenInput, initialValues) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
var entry = document.createElement('input');
|
||||||
|
entry.type = 'text';
|
||||||
|
entry.style.cssText = 'border:0; outline:none; background:transparent; flex:1 1 80px; min-width:80px; color:inherit;';
|
||||||
|
entry.placeholder = 'Type a value, press Enter';
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var chips = Array.prototype.slice.call(container.querySelectorAll('.chip-item')).map(function(c) { return c.dataset.value; });
|
||||||
|
hiddenInput.value = chips.join('\n');
|
||||||
|
}
|
||||||
|
function addChip(value) {
|
||||||
|
value = value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
var chip = document.createElement('span');
|
||||||
|
chip.className = 'badge text-bg-secondary d-inline-flex align-items-center gap-1 chip-item';
|
||||||
|
chip.dataset.value = value;
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.textContent = value;
|
||||||
|
var x = document.createElement('i');
|
||||||
|
x.className = 'bi bi-x';
|
||||||
|
x.addEventListener('click', function() { chip.remove(); sync(); });
|
||||||
|
chip.appendChild(label);
|
||||||
|
chip.appendChild(x);
|
||||||
|
container.insertBefore(chip, entry);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
entry.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
addChip(entry.value);
|
||||||
|
entry.value = '';
|
||||||
|
} else if (e.key === 'Backspace' && entry.value === '') {
|
||||||
|
var last = container.querySelector('.chip-item:last-of-type');
|
||||||
|
if (last) { last.remove(); sync(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
entry.addEventListener('blur', function() {
|
||||||
|
if (entry.value.trim()) { addChip(entry.value); entry.value = ''; }
|
||||||
|
});
|
||||||
|
container.appendChild(entry);
|
||||||
|
(initialValues || []).forEach(addChip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swaps a condition row's value UI between the chip input (free-text fields) and
|
||||||
|
// a fixed select (has_attachment/recipient_type), keeping the row's single
|
||||||
|
// .cond-value-hidden input authoritative either way — the server only ever sees
|
||||||
|
// one condition_value per row regardless of which widget produced it.
|
||||||
|
function updateConditionRowUI(row, initialValue) {
|
||||||
|
var field = row.querySelector('.cond-field').value;
|
||||||
|
var opWrap = row.querySelector('.cond-op-wrap');
|
||||||
|
var opSelect = row.querySelector('.cond-op');
|
||||||
|
var chipWrap = row.querySelector('.chip-input');
|
||||||
|
var fixedSelect = row.querySelector('.cond-fixed-select');
|
||||||
|
var hidden = row.querySelector('.cond-value-hidden');
|
||||||
|
var fixedOptions = RULE_FIXED_FIELD_OPTIONS[field];
|
||||||
|
if (fixedOptions) {
|
||||||
|
opSelect.value = 'equals';
|
||||||
|
opWrap.classList.add('d-none');
|
||||||
|
chipWrap.classList.add('d-none');
|
||||||
|
fixedSelect.classList.remove('d-none');
|
||||||
|
fixedSelect.innerHTML = '';
|
||||||
|
fixedOptions.forEach(function(pair) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = pair[0]; o.textContent = pair[1];
|
||||||
|
fixedSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
if (initialValue) fixedSelect.value = initialValue;
|
||||||
|
hidden.value = fixedSelect.value;
|
||||||
|
fixedSelect.onchange = function() { hidden.value = fixedSelect.value; };
|
||||||
|
} else {
|
||||||
|
opWrap.classList.remove('d-none');
|
||||||
|
chipWrap.classList.remove('d-none');
|
||||||
|
fixedSelect.classList.add('d-none');
|
||||||
|
createChipInput(chipWrap, hidden, initialValue ? initialValue.split('\n') : []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRulesSection() {
|
||||||
|
var addBtn = document.getElementById('add_condition');
|
||||||
|
if (!addBtn || addBtn.dataset.wired) return;
|
||||||
|
addBtn.dataset.wired = '1';
|
||||||
|
var container = document.getElementById('conditions_container');
|
||||||
|
|
||||||
|
function addConditionRow(condition) {
|
||||||
|
var tpl = document.getElementById('condition_row_template');
|
||||||
|
var clone = document.importNode(tpl.content, true);
|
||||||
|
var row = clone.querySelector('.condition-row');
|
||||||
|
row.querySelector('.remove-condition').addEventListener('click', function() {
|
||||||
|
if (container.children.length > 1) row.remove();
|
||||||
|
});
|
||||||
|
container.appendChild(clone);
|
||||||
|
if (condition) {
|
||||||
|
row.querySelector('.cond-field').value = condition.field;
|
||||||
|
row.querySelector('.cond-op').value = condition.op;
|
||||||
|
}
|
||||||
|
updateConditionRowUI(row, condition ? condition.value : null);
|
||||||
|
row.querySelector('.cond-field').addEventListener('change', function() { updateConditionRowUI(row, null); });
|
||||||
|
}
|
||||||
|
addBtn.addEventListener('click', function() { addConditionRow(null); });
|
||||||
|
|
||||||
|
var seedData = [];
|
||||||
|
var seedEl = document.getElementById('editingConditionsData');
|
||||||
|
if (seedEl) {
|
||||||
|
try { seedData = JSON.parse(seedEl.textContent) || []; } catch (e) { seedData = []; }
|
||||||
|
}
|
||||||
|
if (seedData.length) {
|
||||||
|
seedData.forEach(addConditionRow);
|
||||||
|
} else {
|
||||||
|
addConditionRow(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionSelect = document.getElementById('rule_action');
|
||||||
|
var folderInput = document.getElementById('rule_action_value_folder');
|
||||||
|
var forwardInput = document.getElementById('rule_action_value_forward');
|
||||||
|
function updateActionFields() {
|
||||||
|
var action = actionSelect.value;
|
||||||
|
document.querySelectorAll('.action-extra-fields > div').forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
var shown = document.querySelector('.action-field-' + action);
|
||||||
|
if (shown) shown.style.display = 'block';
|
||||||
|
folderInput.disabled = action !== 'move_to_folder';
|
||||||
|
forwardInput.disabled = action !== 'forward';
|
||||||
|
}
|
||||||
|
if (actionSelect) {
|
||||||
|
actionSelect.addEventListener('change', updateActionFields);
|
||||||
|
updateActionFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSignaturesSection() {
|
||||||
|
var editorEl = document.getElementById('sigEditor');
|
||||||
|
var modalEl = document.getElementById('sigModal');
|
||||||
|
if (!editorEl || !modalEl) return;
|
||||||
|
var sigQuill = new Quill('#sigEditor', {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: { toolbar: [['bold', 'italic', 'underline'], [{ color: [] }], ['link', 'image'], ['clean']] },
|
||||||
|
});
|
||||||
|
document.getElementById('sigForm').addEventListener('submit', function() {
|
||||||
|
document.querySelector('[name="content_html"]').value = sigQuill.root.innerHTML;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetAliasDefaultFields() {
|
||||||
|
var emailSel = modalEl.querySelector('[name="default_for_email"]');
|
||||||
|
var newChk = document.getElementById('sigDefaultNewAlias');
|
||||||
|
var replyChk = document.getElementById('sigDefaultReplyAlias');
|
||||||
|
if (emailSel) emailSel.value = '';
|
||||||
|
if (newChk) newChk.checked = false;
|
||||||
|
if (replyChk) replyChk.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var addBtn = document.getElementById('sigAddBtn');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('sigModalTitle').textContent = 'New signature';
|
||||||
|
document.getElementById('sigFormId').value = '';
|
||||||
|
document.getElementById('sigFormName').value = '';
|
||||||
|
sigQuill.setText('');
|
||||||
|
resetAliasDefaultFields();
|
||||||
|
new bootstrap.Modal(modalEl).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.sig-edit-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var card = btn.closest('.sig-card');
|
||||||
|
document.getElementById('sigModalTitle').textContent = 'Edit signature';
|
||||||
|
document.getElementById('sigFormId').value = card.dataset.sigId;
|
||||||
|
document.getElementById('sigFormName').value = card.dataset.sigName;
|
||||||
|
var content = card.querySelector('.sig-content-data');
|
||||||
|
sigQuill.root.innerHTML = content ? content.innerHTML : '';
|
||||||
|
resetAliasDefaultFields();
|
||||||
|
new bootstrap.Modal(modalEl).show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initContactsSection() {
|
||||||
|
var modalEl = document.getElementById('contactModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
var addBtn = document.getElementById('contactAddBtn');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('contactModalTitle').textContent = 'New contact';
|
||||||
|
document.getElementById('contactFormId').value = '';
|
||||||
|
document.getElementById('contactFormName').value = '';
|
||||||
|
document.getElementById('contactFormEmail').value = '';
|
||||||
|
document.getElementById('contactFormPhone').value = '';
|
||||||
|
new bootstrap.Modal(modalEl).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.contact-edit-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var row = btn.closest('.contact-row');
|
||||||
|
document.getElementById('contactModalTitle').textContent = 'Edit contact';
|
||||||
|
document.getElementById('contactFormId').value = row.dataset.contactId;
|
||||||
|
document.getElementById('contactFormName').value = row.dataset.contactName;
|
||||||
|
document.getElementById('contactFormEmail').value = row.dataset.contactEmail;
|
||||||
|
document.getElementById('contactFormPhone').value = row.dataset.contactPhone;
|
||||||
|
new bootstrap.Modal(modalEl).show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlToBuf(s) {
|
||||||
|
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (s.length % 4) s += '=';
|
||||||
|
var bin = atob(s);
|
||||||
|
var buf = new Uint8Array(bin.length);
|
||||||
|
for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||||
|
return buf.buffer;
|
||||||
|
}
|
||||||
|
function bufToB64url(buf) {
|
||||||
|
var bytes = new Uint8Array(buf);
|
||||||
|
var bin = '';
|
||||||
|
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||||
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
function initAccountSection() {
|
||||||
|
var passkeyAddBtn = document.getElementById('passkey-add-btn');
|
||||||
|
if (!passkeyAddBtn) return;
|
||||||
|
passkeyAddBtn.addEventListener('click', async function() {
|
||||||
|
var errEl = document.getElementById('passkey-error');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
try {
|
||||||
|
var beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
|
||||||
|
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
|
||||||
|
var options = await beginResp.json();
|
||||||
|
var publicKey = options.publicKey;
|
||||||
|
publicKey.challenge = b64urlToBuf(publicKey.challenge);
|
||||||
|
publicKey.user.id = b64urlToBuf(publicKey.user.id);
|
||||||
|
if (publicKey.excludeCredentials) {
|
||||||
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
|
||||||
|
}
|
||||||
|
var cred = await navigator.credentials.create({ publicKey });
|
||||||
|
var body = {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: bufToB64url(cred.response.attestationObject),
|
||||||
|
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var finishResp = await fetch('/webmail/account/passkey/finish', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message || 'Passkey registration failed';
|
||||||
|
errEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initSettingsSection);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -4,36 +4,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Signatures - Webmail</title>
|
<title>Settings - Webmail</title>
|
||||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||||
.sig-preview { background-color: #fff; color: #000; border-radius: 6px; padding: .75rem; max-height: 140px; overflow: hidden; }
|
|
||||||
#sigEditor { height: 180px; background-color: #fff; color: #000; }
|
|
||||||
.ql-toolbar.ql-snow { background-color: #333; border-color: #404040; border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
|
|
||||||
.ql-container.ql-snow { border-color: #404040; border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
|
|
||||||
.ql-snow .ql-stroke { stroke: #c8c8c8; }
|
|
||||||
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
|
|
||||||
.ql-snow .ql-picker { color: #c8c8c8; }
|
|
||||||
.ql-snow .ql-picker-options { background-color: #2d2d2d; border-color: #404040; }
|
|
||||||
.ql-snow .ql-picker-item { color: #c8c8c8; }
|
|
||||||
</style>
|
</style>
|
||||||
|
{{template "webmail_settings_style" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "csrf_script" .}}
|
{{template "csrf_script" .}}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
|
||||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
|
||||||
<a href="/webmail/signatures" class="btn btn-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
|
||||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
|
||||||
<form method="post" action="/webmail/logout" class="d-inline">
|
<form method="post" action="/webmail/logout" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -55,31 +43,39 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container pb-5">
|
<div id="settingsPanelRoot">
|
||||||
<h2 class="mb-4"><i class="bi bi-pen me-2"></i>Signatures</h2>
|
<div class="settings-card" id="settingsPanel">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Settings</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" onclick="closeSettings()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card-body">
|
||||||
|
{{template "webmail_settings_nav" .}}
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0">Your signatures</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="sigAddBtn"><i class="bi bi-plus-lg me-1"></i>Add signature</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6 mb-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h5 class="mb-0">Your signatures</h5></div>
|
|
||||||
<div class="card-body">
|
|
||||||
{{if .signatures}}
|
{{if .signatures}}
|
||||||
{{range .signatures}}
|
{{range .signatures}}
|
||||||
<div class="border rounded p-2 mb-3" style="border-color: #404040 !important;">
|
<div class="card mb-3 sig-card" data-sig-id="{{.ID}}" data-sig-name="{{.Name}}">
|
||||||
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{.Name}}</strong>
|
<strong>{{.Name}}</strong>
|
||||||
{{if .IsDefaultNew}}<span class="badge bg-primary ms-1">Default: New</span>{{end}}
|
{{if .IsDefaultNew}}<span class="badge bg-primary ms-1">Default: New</span>{{end}}
|
||||||
{{if .IsDefaultReply}}<span class="badge bg-info text-dark ms-1">Default: Reply/Forward</span>{{end}}
|
{{if .IsDefaultReply}}<span class="badge bg-info text-dark ms-1">Default: Reply</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="/webmail/signatures?edit={{.ID}}" class="btn btn-outline-secondary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<button type="button" class="btn btn-outline-secondary sig-edit-btn" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
<form method="post" action="/webmail/signatures/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete signature "{{.Name}}"?');">
|
<form method="post" action="/webmail/signatures/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete signature "{{.Name}}"?');">
|
||||||
<button type="submit" class="btn btn-outline-danger" title="Delete"><i class="bi bi-trash"></i></button>
|
<button type="submit" class="btn btn-outline-danger" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sig-preview mb-2">{{.ContentHTML | safe}}</div>
|
<div class="sig-preview mb-2">{{.ContentHTML | safe}}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<form method="post" action="/webmail/signatures/{{if .IsDefaultNew}}0{{else}}{{.ID}}{{end}}/default?which=new" class="d-inline">
|
<form method="post" action="/webmail/signatures/{{if .IsDefaultNew}}0{{else}}{{.ID}}{{end}}/default?which=new" class="d-inline">
|
||||||
<button type="submit" class="btn {{if .IsDefaultNew}}btn-primary{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultNew}}✓ Default for new{{else}}Use for new{{end}}</button>
|
<button type="submit" class="btn {{if .IsDefaultNew}}btn-primary{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultNew}}✓ Default for new{{else}}Use for new{{end}}</button>
|
||||||
@@ -88,6 +84,22 @@
|
|||||||
<button type="submit" class="btn {{if .IsDefaultReply}}btn-info text-dark{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultReply}}✓ Default for reply/forward{{else}}Use for reply/forward{{end}}</button>
|
<button type="submit" class="btn {{if .IsDefaultReply}}btn-info text-dark{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultReply}}✓ Default for reply/forward{{else}}Use for reply/forward{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{{$sigID := .ID}}
|
||||||
|
{{range $.alias_defaults}}
|
||||||
|
{{if eq .SignatureID $sigID}}
|
||||||
|
<span class="badge bg-secondary d-inline-flex align-items-center gap-1">
|
||||||
|
{{if .ForReply}}Reply{{else}}New{{end}} for {{.ForEmail}}
|
||||||
|
<form method="post" action="/webmail/signatures/alias-default/remove" class="d-inline">
|
||||||
|
<input type="hidden" name="for_email" value="{{.ForEmail}}">
|
||||||
|
{{if .ForReply}}<input type="hidden" name="for_reply" value="1">{{end}}
|
||||||
|
<button type="submit" class="btn btn-link btn-sm p-0 text-white" style="text-decoration:none;" title="Remove"><i class="bi bi-x"></i></button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="sig-content-data" style="display:none;">{{.ContentHTML | safe}}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -96,54 +108,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="modal fade" id="sigModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="card">
|
<div class="modal-dialog">
|
||||||
<div class="card-header"><h5 class="mb-0">{{if .editing}}Edit signature{{else}}New signature{{end}}</h5></div>
|
<div class="modal-content">
|
||||||
<div class="card-body">
|
|
||||||
<form method="POST" action="/webmail/signatures/save" id="sigForm">
|
<form method="POST" action="/webmail/signatures/save" id="sigForm">
|
||||||
<input type="hidden" name="id" value="{{if .editing}}{{.editing.ID}}{{end}}">
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="sigModalTitle">New signature</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="id" id="sigFormId" value="">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<input type="text" class="form-control" name="name" value="{{if .editing}}{{.editing.Name}}{{end}}" placeholder="e.g. Work, Personal" required>
|
<input type="text" class="form-control" name="name" id="sigFormName" placeholder="e.g. Work, Personal" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Content</label>
|
<label class="form-label">Content</label>
|
||||||
<div id="sigEditor"></div>
|
<div id="sigEditor"></div>
|
||||||
<textarea name="content_html" style="display: none;"></textarea>
|
<textarea name="content_html" style="display: none;"></textarea>
|
||||||
<div id="sig_html_seed" style="display: none;">{{if .editing}}{{.editing.ContentHTML}}{{end}}</div>
|
|
||||||
<div class="form-text">Plain text works fine too — just don't use the formatting toolbar. A blank signature is also valid (e.g. to temporarily disable one without deleting it).</div>
|
<div class="form-text">Plain text works fine too — just don't use the formatting toolbar. A blank signature is also valid (e.g. to temporarily disable one without deleting it).</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .send_as_options}}
|
||||||
|
<div class="border rounded p-2" style="border-color: #404040 !important;">
|
||||||
|
<label class="form-label mb-1">Also set as default for a specific alias</label>
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<select class="form-select form-select-sm" name="default_for_email" style="width: auto;">
|
||||||
|
<option value="">— choose an alias —</option>
|
||||||
|
{{range .send_as_options}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
<div class="form-check mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" name="default_new" value="1" id="sigDefaultNewAlias">
|
||||||
|
<label class="form-check-label" for="sigDefaultNewAlias">New messages</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" name="default_reply" value="1" id="sigDefaultReplyAlias">
|
||||||
|
<label class="form-check-label" for="sigDefaultReplyAlias">Replies/forwards</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
|
||||||
{{if .editing}}<a href="/webmail/signatures" class="btn btn-outline-secondary">Cancel</a>{{end}}
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{template "compose_widget" .}}
|
{{template "compose_widget" .}}
|
||||||
|
|
||||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||||
<script>
|
{{template "webmail_settings_script" .}}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
var sigQuill = new Quill('#sigEditor', {
|
|
||||||
theme: 'snow',
|
|
||||||
modules: { toolbar: [['bold', 'italic', 'underline'], [{ color: [] }], ['link', 'image'], ['clean']] },
|
|
||||||
});
|
|
||||||
(function() {
|
|
||||||
const seed = document.getElementById('sig_html_seed');
|
|
||||||
if (seed && seed.innerHTML.trim()) sigQuill.root.innerHTML = seed.innerHTML;
|
|
||||||
})();
|
|
||||||
document.getElementById('sigForm').addEventListener('submit', function() {
|
|
||||||
document.querySelector('[name="content_html"]').value = sigQuill.root.innerHTML;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
||||||
"trusted_senders": trustedSenders,
|
"trusted_senders": trustedSenders,
|
||||||
"flashes": popFlashes(w, r),
|
"flashes": popFlashes(w, r),
|
||||||
|
"active_section": "account",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webmailListTypes are the only list_types a mailbox owner may self-manage — "block"
|
||||||
|
// (admin's hard-reject-at-RCPT list) is deliberately excluded, see
|
||||||
|
// esrv_mailbox_allowblock's schema comment.
|
||||||
|
var webmailListTypes = map[string]bool{"allow": true, "junk": true}
|
||||||
|
|
||||||
|
// webmailBlocklistPage shows a mailbox owner's own Blocklist ("junk" entries — mail
|
||||||
|
// from these addresses/domains is always quarantined straight to Junk, bypassing
|
||||||
|
// spam scoring) and Whitelist ("allow" entries — never scored as spam, always lands
|
||||||
|
// in INBOX; the escape hatch for a trusted sender rspamd or the built-in heuristic
|
||||||
|
// keeps flagging).
|
||||||
|
func (a *App) webmailBlocklistPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
entries, err := a.DB.ListAllowBlock(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "Error loading lists")
|
||||||
|
}
|
||||||
|
var blocked, allowed []db.MailboxAllowBlockEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
switch e.ListType {
|
||||||
|
case "junk":
|
||||||
|
blocked = append(blocked, e)
|
||||||
|
case "allow":
|
||||||
|
allowed = append(allowed, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.render(w, r, "webmail_blocklist.html", M{
|
||||||
|
"mailbox": mbox, "blocked": blocked, "allowed": allowed,
|
||||||
|
"flashes": popFlashes(w, r), "active_section": "blocklist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) webmailBlocklistAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
pattern := strings.ToLower(strings.TrimSpace(r.FormValue("pattern")))
|
||||||
|
listType := r.FormValue("list_type")
|
||||||
|
if pattern == "" || !webmailListTypes[listType] {
|
||||||
|
setFlash(w, "error", "Enter an address (or @domain.com) and choose a list")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/blocklist", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.DB.AddAllowBlockEntry(mbox.ID, listType, pattern); err != nil {
|
||||||
|
setFlash(w, "error", "Error adding entry")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Added")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/blocklist", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) webmailBlocklistRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
id := int64(atoi(r.PathValue("id")))
|
||||||
|
if err := a.DB.RemoveAllowBlockEntry(id, mbox.ID); err != nil {
|
||||||
|
setFlash(w, "error", "Error removing entry")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Removed")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/blocklist", http.StatusFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebmailMarkAsJunkAddsToBlocklistNotRules reproduces the intended behavior
|
||||||
|
// change: "Mark as Junk" used to create a filter rule (mark_as_spam action); it now
|
||||||
|
// adds the sender to the mailbox's own Blocklist ("junk" allowblock entries) instead,
|
||||||
|
// leaving the Rules list untouched.
|
||||||
|
func TestWebmailMarkAsJunkAddsToBlocklistNotRules(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "junker@example.com", domains[0].ID, "junker-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
uid := storeTestMessage(t, app, mailboxID, "INBOX", "spammer@example.com", "buy now", "body")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/mark-junk", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("mark as junk: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
junked, err := app.DB.IsJunked(mailboxID, "spammer@example.com")
|
||||||
|
if err != nil || !junked {
|
||||||
|
t.Fatalf("expected spammer@example.com added to the blocklist, junked=%v err=%v", junked, err)
|
||||||
|
}
|
||||||
|
rules, err := app.DB.ListRulesForMailbox(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rules) != 0 {
|
||||||
|
t.Fatalf("expected no filter rule created (blocklist replaces the old rule-based flow), got %+v", rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := app.DB.ListMessagesInFolder(mailboxID, "Junk")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected the message itself moved to Junk, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailBlocklistAddAndRemove exercises the self-service Blocklist/Whitelist
|
||||||
|
// page's add+remove flow for both list types.
|
||||||
|
func TestWebmailBlocklistAddAndRemove(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "lister@example.com", domains[0].ID, "lister-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
add := func(pattern, listType string) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"pattern": {pattern}, "list_type": {listType}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/blocklist/add", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := add("bad@example.com", "junk"); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("add junk: status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := add("good@example.com", "allow"); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("add allow: status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := app.DB.ListAllowBlock(mailboxID)
|
||||||
|
if err != nil || len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d (err=%v)", len(entries), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "block" (admin's hard-reject list) must not be settable from this self-service
|
||||||
|
// endpoint.
|
||||||
|
if rec := add("someone@example.com", "block"); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
entries, err = app.DB.ListAllowBlock(mailboxID)
|
||||||
|
if err != nil || len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 'block' rejected (still 2 entries), got %d (err=%v)", len(entries), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var junkID int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.ListType == "junk" {
|
||||||
|
junkID = e.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/blocklist/"+strconv.FormatInt(junkID, 10)+"/remove", nil)
|
||||||
|
rmReq.AddCookie(cookie)
|
||||||
|
rmRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rmRec, rmReq)
|
||||||
|
if rmRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("remove: status=%d", rmRec.Code)
|
||||||
|
}
|
||||||
|
remaining, err := app.DB.ListAllowBlock(mailboxID)
|
||||||
|
if err != nil || len(remaining) != 1 || remaining[0].ListType != "allow" {
|
||||||
|
t.Fatalf("expected only the allow entry left, got %+v (err=%v)", remaining, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailBlocklistScopedToOwnMailbox confirms one mailbox owner can't remove
|
||||||
|
// another mailbox's blocklist entry by guessing its ID.
|
||||||
|
func TestWebmailBlocklistScopedToOwnMailbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
victimID := createTestMailboxWithPassword(t, app, "victim3@example.com", domains[0].ID, "victim-password-1!")
|
||||||
|
attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domains[0].ID, "attacker-password-1!")
|
||||||
|
|
||||||
|
entryID, err := app.DB.AddAllowBlockEntry(victimID, "junk", "spam@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attackerCookie := webmailLoginSession(t, app, attackerID)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/blocklist/"+strconv.FormatInt(entryID, 10)+"/remove", nil)
|
||||||
|
req.AddCookie(attackerCookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
stillThere, err := app.DB.ListAllowBlock(victimID)
|
||||||
|
if err != nil || len(stillThere) != 1 {
|
||||||
|
t.Fatalf("expected the victim's entry untouched, got %d (err=%v)", len(stillThere), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,21 +26,50 @@ import (
|
|||||||
|
|
||||||
const maxComposeUploadBytes = 25 << 20 // 25MB, matching a typical provider's attachment cap
|
const maxComposeUploadBytes = 25 << 20 // 25MB, matching a typical provider's attachment cap
|
||||||
|
|
||||||
|
// sendAsAddresses returns a mailbox's active, send-as-capable aliases — the set of
|
||||||
|
// extra "From" addresses compose (and, for signatures, per-alias defaults) offers
|
||||||
|
// alongside the mailbox's own primary address.
|
||||||
|
func (a *App) sendAsAddresses(mbox *db.Mailbox) []string {
|
||||||
|
aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID)
|
||||||
|
var out []string
|
||||||
|
for _, al := range aliases {
|
||||||
|
if al.CanSendAs && al.IsActive {
|
||||||
|
out = append(out, al.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// composeFormData builds the template data every compose-page render needs
|
// composeFormData builds the template data every compose-page render needs
|
||||||
// regardless of why it's rendering (a fresh GET, a reply/forward prefill, or
|
// regardless of why it's rendering (a fresh GET, a reply/forward prefill, or
|
||||||
// redisplaying the form after a failed send) — shared so those three paths can't
|
// redisplaying the form after a failed send) — shared so those three paths can't
|
||||||
// drift out of sync with each other.
|
// drift out of sync with each other.
|
||||||
func (a *App) composeFormData(mbox *db.Mailbox) M {
|
func (a *App) composeFormData(mbox *db.Mailbox) M {
|
||||||
aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID)
|
sendAsOptions := a.sendAsAddresses(mbox)
|
||||||
var sendAsOptions []string
|
|
||||||
for _, al := range aliases {
|
|
||||||
if al.CanSendAs && al.IsActive {
|
|
||||||
sendAsOptions = append(sendAsOptions, al.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
|
identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
|
||||||
pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
|
pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
|
||||||
signatures, _ := a.DB.ListSignatures(mbox.ID)
|
signatures, _ := a.DB.ListSignatures(mbox.ID)
|
||||||
|
|
||||||
|
// One default-signature-id lookup per (address, new-or-reply) pair, embedded as a
|
||||||
|
// JS map so compose can swap the inserted signature live when the From-alias
|
||||||
|
// picker changes — see webmail_compose.html's from-select handler. Otherwise a
|
||||||
|
// per-alias default (Settings > Signatures) would only ever apply to the mailbox's
|
||||||
|
// own primary address, since that's the only "from" compose knows about at
|
||||||
|
// initial page load (the alias picker itself is client-side only).
|
||||||
|
addresses := append([]string{mbox.Email}, sendAsOptions...)
|
||||||
|
sigDefaults := make(map[string]map[string]int64, len(addresses))
|
||||||
|
for _, addr := range addresses {
|
||||||
|
entry := map[string]int64{"new": 0, "reply": 0}
|
||||||
|
if sig, err := a.DB.GetDefaultSignature(mbox.ID, false, addr); err == nil && sig != nil {
|
||||||
|
entry["new"] = sig.ID
|
||||||
|
}
|
||||||
|
if sig, err := a.DB.GetDefaultSignature(mbox.ID, true, addr); err == nil && sig != nil {
|
||||||
|
entry["reply"] = sig.ID
|
||||||
|
}
|
||||||
|
sigDefaults[addr] = entry
|
||||||
|
}
|
||||||
|
sigDefaultsJSON, _ := json.Marshal(sigDefaults)
|
||||||
|
|
||||||
return M{
|
return M{
|
||||||
"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts,
|
"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts,
|
||||||
"signatures": signatures,
|
"signatures": signatures,
|
||||||
@@ -48,6 +77,9 @@ func (a *App) composeFormData(mbox *db.Mailbox) M {
|
|||||||
// $.default_signature_id .ID}} comparison never has to handle a nil operand —
|
// $.default_signature_id .ID}} comparison never has to handle a nil operand —
|
||||||
// 0 is a safe "no default" sentinel since real ids start at 1 (AUTOINCREMENT).
|
// 0 is a safe "no default" sentinel since real ids start at 1 (AUTOINCREMENT).
|
||||||
"default_signature_id": int64(0),
|
"default_signature_id": int64(0),
|
||||||
|
// See json.Marshal HTML-escaping note on webmailRulesList's identical pattern —
|
||||||
|
// safe to mark template.JS since json.Marshal already escapes <, >, &.
|
||||||
|
"signature_defaults_json": template.JS(sigDefaultsJSON),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +116,12 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
uidStr, mode = q.Get("draft"), "draft"
|
uidStr, mode = q.Get("draft"), "draft"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposed to JS so the from-select handler knows which slot ("new" or "reply") of
|
||||||
|
// signature_defaults_json to use when the picked address changes — a draft is
|
||||||
|
// treated like "new" here (matching the auto-insertion skip below: a draft's
|
||||||
|
// signature situation, if any, is whatever was already saved in it).
|
||||||
|
data["compose_for_reply"] = mode == "reply" || mode == "replyall" || mode == "forward"
|
||||||
|
|
||||||
if mode != "" && folder != "" {
|
if mode != "" && folder != "" {
|
||||||
if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
|
if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -126,7 +164,7 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
// when it was saved, or none, and re-adding one here would duplicate it.
|
// when it was saved, or none, and re-adding one here would duplicate it.
|
||||||
if mode != "draft" {
|
if mode != "draft" {
|
||||||
forReply := mode == "reply" || mode == "replyall" || mode == "forward"
|
forReply := mode == "reply" || mode == "replyall" || mode == "forward"
|
||||||
if sig, err := a.DB.GetDefaultSignature(mbox.ID, forReply); err == nil && sig != nil {
|
if sig, err := a.DB.GetDefaultSignature(mbox.ID, forReply, ""); err == nil && sig != nil {
|
||||||
data["default_signature_id"] = sig.ID
|
data["default_signature_id"] = sig.ID
|
||||||
if wrapped := wrapSignatureHTML(sig.ContentHTML); wrapped != "" {
|
if wrapped := wrapSignatureHTML(sig.ContentHTML); wrapped != "" {
|
||||||
existing, _ := data["body_html"].(template.HTML)
|
existing, _ := data["body_html"].(template.HTML)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webmailContactsPage lists a mailbox owner's saved contacts — add/edit happens in a
|
||||||
|
// popup (webmail_settings_chrome.html's contact modal), mirroring signatures.
|
||||||
|
func (a *App) webmailContactsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
contacts, err := a.DB.ListContacts(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "Error loading contacts")
|
||||||
|
}
|
||||||
|
a.render(w, r, "webmail_contacts.html", M{
|
||||||
|
"mailbox": mbox, "contacts": contacts,
|
||||||
|
"flashes": popFlashes(w, r), "active_section": "contacts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// webmailContactSave creates a new contact, or updates one when id (a hidden form
|
||||||
|
// field, not a path segment) is set — mirrors webmailSignatureSave.
|
||||||
|
func (a *App) webmailContactSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
phone := strings.TrimSpace(r.FormValue("phone"))
|
||||||
|
if email == "" || name == "" {
|
||||||
|
setFlash(w, "error", "A contact needs at least a name and an email address")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/contacts", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := int64(atoi(r.FormValue("id")))
|
||||||
|
var err error
|
||||||
|
if id != 0 {
|
||||||
|
err = a.DB.UpdateContact(mbox.ID, id, email, name, phone)
|
||||||
|
} else {
|
||||||
|
_, err = a.DB.CreateContact(mbox.ID, email, name, phone)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "Could not save the contact — an entry with that email may already exist")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Contact saved")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/contacts", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) webmailContactDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
id := int64(atoi(r.PathValue("id")))
|
||||||
|
if err := a.DB.DeleteContact(mbox.ID, id); err != nil {
|
||||||
|
setFlash(w, "error", "Could not delete the contact")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Contact deleted")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/contacts", http.StatusFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebmailContactCreateEditDelete exercises the full self-service contact CRUD
|
||||||
|
// flow through the add/edit popup's shared save endpoint.
|
||||||
|
func TestWebmailContactCreateEditDelete(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "contactowner@example.com", domains[0].ID, "contact-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
save := func(id, email, name, phone string) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"id": {id}, "email": {email}, "name": {name}, "phone": {phone}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/contacts/save", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := save("", "jane@example.com", "Jane Doe", "555-1234"); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("create: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
contacts, err := app.DB.ListContacts(mailboxID)
|
||||||
|
if err != nil || len(contacts) != 1 || contacts[0].Name != "Jane Doe" {
|
||||||
|
t.Fatalf("expected 1 contact named Jane Doe, got %+v (err=%v)", contacts, err)
|
||||||
|
}
|
||||||
|
id := contacts[0].ID
|
||||||
|
|
||||||
|
if rec := save(strconv.FormatInt(id, 10), "jane@example.com", "Jane D.", ""); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("edit: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
updated, err := app.DB.GetContactByID(mailboxID, id)
|
||||||
|
if err != nil || updated == nil || updated.Name != "Jane D." || updated.Phone != "" {
|
||||||
|
t.Fatalf("expected updated contact, got %+v (err=%v)", updated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing name/email is rejected rather than silently stored.
|
||||||
|
if rec := save("", "", "No Email", ""); rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
contacts, err = app.DB.ListContacts(mailboxID)
|
||||||
|
if err != nil || len(contacts) != 1 {
|
||||||
|
t.Fatalf("expected the invalid contact rejected (still 1), got %d (err=%v)", len(contacts), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/contacts/"+strconv.FormatInt(id, 10)+"/delete", nil)
|
||||||
|
delReq.AddCookie(cookie)
|
||||||
|
delRec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(delRec, delReq)
|
||||||
|
if delRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("delete: status=%d body=%s", delRec.Code, delRec.Body.String())
|
||||||
|
}
|
||||||
|
remaining, err := app.DB.ListContacts(mailboxID)
|
||||||
|
if err != nil || len(remaining) != 0 {
|
||||||
|
t.Fatalf("expected no contacts left, got %+v (err=%v)", remaining, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailContactScopedToOwnMailbox confirms one mailbox owner can't delete
|
||||||
|
// another mailbox's contact by guessing its ID.
|
||||||
|
func TestWebmailContactScopedToOwnMailbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
victimID := createTestMailboxWithPassword(t, app, "victim4@example.com", domains[0].ID, "victim-password-1!")
|
||||||
|
attackerID := createTestMailboxWithPassword(t, app, "attacker4@example.com", domains[0].ID, "attacker-password-1!")
|
||||||
|
|
||||||
|
contactID, err := app.DB.CreateContact(victimID, "friend@example.com", "Friend", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attackerCookie := webmailLoginSession(t, app, attackerID)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/contacts/"+strconv.FormatInt(contactID, 10)+"/delete", nil)
|
||||||
|
req.AddCookie(attackerCookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
stillThere, err := app.DB.GetContactByID(victimID, contactID)
|
||||||
|
if err != nil || stillThere == nil {
|
||||||
|
t.Fatalf("expected the victim's contact untouched, got %+v (err=%v)", stillThere, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -646,12 +646,12 @@ func (a *App) webmailRestoreMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// webmailMarkAsJunk moves one message to Junk and, unless one already exists, adds a
|
// webmailMarkAsJunk moves one message to Junk and, unless already there, adds the
|
||||||
// filter rule ("from" contains this sender's address -> mark_as_spam) so future mail
|
// sender to the mailbox's own Blocklist (esrv_mailbox_allowblock, list_type "junk")
|
||||||
// from them routes straight to Junk at delivery time (mailstore.ApplyRules) — the
|
// so future mail from them routes straight to Junk at delivery time (db.IsJunked,
|
||||||
// "blacklist" the sender asked for, reusing the existing Rules feature rather than a
|
// checked in smtpserver's deliverLocally) without needing to build a filter rule by
|
||||||
// separate mechanism: it shows up, and can be removed at any time, from the same
|
// hand — the "blacklist" the sender asked for, visible/removable from the Blocklist
|
||||||
// Rules page as everything else.
|
// page in Settings.
|
||||||
func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
folder := r.PathValue("folder")
|
folder := r.PathValue("folder")
|
||||||
@@ -676,10 +676,18 @@ func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg := "Message marked as junk"
|
msg := "Message marked as junk"
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
if senderEmail := extractAddress(parsed.Header.From); senderEmail != "" {
|
if senderEmail := extractAddress(parsed.Header.From); senderEmail != "" {
|
||||||
if added, err := a.ensureJunkRuleForSender(mbox.ID, senderEmail); err != nil {
|
// AddAllowBlockEntry's own INSERT OR IGNORE (backed by the table's UNIQUE
|
||||||
a.Logger.Error("create junk rule for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
// constraint) already makes this idempotent — marking several messages
|
||||||
} else if added {
|
// from the same repeat sender as junk doesn't pile up duplicate entries.
|
||||||
msg = "Message marked as junk — future mail from " + senderEmail + " will go there too (see Rules to undo)"
|
alreadyBlocked, err := a.DB.IsJunked(mbox.ID, senderEmail)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("check blocklist for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
||||||
|
} else if !alreadyBlocked {
|
||||||
|
if _, err := a.DB.AddAllowBlockEntry(mbox.ID, "junk", senderEmail); err != nil {
|
||||||
|
a.Logger.Error("add blocklist entry for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
||||||
|
} else {
|
||||||
|
msg = "Message marked as junk — future mail from " + senderEmail + " will go there too (see Blocklist to undo)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,32 +695,6 @@ func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
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)
|
// webmailMessageMove reassigns a message to a different (existing or freshly named)
|
||||||
// folder, e.g. from the message view's "Move to..." control.
|
// folder, e.g. from the message view's "Move to..." control.
|
||||||
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,30 +1,69 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// webmailRulesList is the self-service mirror of rulesList (mailbox_rules.go) — same
|
// webmailRulesList is the self-service mirror of rulesList (mailbox_rules.go) — same
|
||||||
// underlying CRUD (ListRulesForMailbox/CreateRule/RemoveRule), just reached from the
|
// underlying CRUD (ListRulesForMailbox/CreateRuleMulti/UpdateRuleMulti/RemoveRule),
|
||||||
// mailbox owner's own portal instead of an admin managing it on their behalf.
|
// just reached from the mailbox owner's own portal instead of an admin managing it on
|
||||||
|
// their behalf. ?edit=<id> loads an existing rule into the builder instead of a blank
|
||||||
|
// one, mirroring webmailSignaturesPage's ?edit= pattern.
|
||||||
func (a *App) webmailRulesList(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailRulesList(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
rules, err := a.DB.ListRulesForMailbox(mbox.ID)
|
rules, err := a.DB.ListRulesForMailbox(mbox.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
setFlash(w, "error", "Error loading rules")
|
setFlash(w, "error", "Error loading rules")
|
||||||
}
|
}
|
||||||
a.render(w, r, "webmail_rules.html", M{"mailbox": mbox, "rules": rules, "flashes": popFlashes(w, r)})
|
|
||||||
|
var editing *db.MailboxFilterRule
|
||||||
|
editingConditions := []db.RuleCondition{}
|
||||||
|
var editingMatchType string
|
||||||
|
if idStr := r.URL.Query().Get("edit"); idStr != "" {
|
||||||
|
editing, _ = a.DB.GetRuleByID(mbox.ID, int64(atoi(idStr)))
|
||||||
|
if editing != nil {
|
||||||
|
editingConditions, editingMatchType = editing.Conditions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// json.Marshal HTML-escapes <, >, & by default (Go's documented behavior
|
||||||
|
// specifically for safe embedding in HTML/script contexts), so this is safe to
|
||||||
|
// mark template.JS and emit raw — a condition value containing "</script>" can't
|
||||||
|
// break out of the tag. Plain string would instead get html/template's own
|
||||||
|
// JS-value auto-escaping applied on top, which quotes the whole blob as a single
|
||||||
|
// JS string literal (mangling it — confirmed live, JSON.parse('"null"') is the
|
||||||
|
// string "null", not an array, so seedData.forEach threw).
|
||||||
|
editingConditionsJSON, _ := json.Marshal(editingConditions)
|
||||||
|
|
||||||
|
a.render(w, r, "webmail_rules.html", M{
|
||||||
|
"mailbox": mbox, "rules": rules, "flashes": popFlashes(w, r), "active_section": "rules",
|
||||||
|
"editing": editing, "editing_match_type": editingMatchType,
|
||||||
|
"editing_conditions_json": template.JS(editingConditionsJSON),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) webmailAddRule(w http.ResponseWriter, r *http.Request) {
|
// webmailSaveRule creates a new rule, or updates one when rule_id (a hidden form
|
||||||
|
// field, not a path segment — one form reused for add and edit, mirroring
|
||||||
|
// webmailSignatureSave) is set and non-zero.
|
||||||
|
func (a *App) webmailSaveRule(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
setFlash(w, "error", "Invalid form submission")
|
setFlash(w, "error", "Invalid form submission")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ruleID := int64(atoi(r.FormValue("rule_id")))
|
||||||
|
redirectTarget := MailboxPrefix + "/rules"
|
||||||
|
if ruleID != 0 {
|
||||||
|
redirectTarget += "?edit=" + strconv.FormatInt(ruleID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
priority, _ := strconv.Atoi(r.FormValue("priority"))
|
priority, _ := strconv.Atoi(r.FormValue("priority"))
|
||||||
matchType := r.FormValue("match_type")
|
matchType := r.FormValue("match_type")
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
@@ -33,18 +72,64 @@ func (a *App) webmailAddRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
conditions, ok := parseRuleConditions(r)
|
conditions, ok := parseRuleConditions(r)
|
||||||
if !ok || !validActions[action] {
|
if !ok || !validActions[action] {
|
||||||
setFlash(w, "error", "Please fill in a valid condition and action")
|
setFlash(w, "error", "Please fill in a valid condition and action")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
http.Redirect(w, r, redirectTarget, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if action == "move_to_folder" && actionValue == "" {
|
if action == "move_to_folder" && actionValue == "" {
|
||||||
setFlash(w, "error", "Please name the folder to move matching mail into")
|
setFlash(w, "error", "Please name the folder to move matching mail into")
|
||||||
|
http.Redirect(w, r, redirectTarget, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "forward" && !strings.Contains(actionValue, "@") {
|
||||||
|
setFlash(w, "error", "Please enter a valid address to forward to")
|
||||||
|
http.Redirect(w, r, redirectTarget, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionOptionsJSON := ""
|
||||||
|
if action == "forward" {
|
||||||
|
opts := db.RuleActionOptions{KeepCopy: r.FormValue("keep_copy") != ""}
|
||||||
|
if b, err := json.Marshal(opts); err == nil {
|
||||||
|
actionOptionsJSON = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if ruleID != 0 {
|
||||||
|
err = a.DB.UpdateRuleMulti(mbox.ID, ruleID, priority, conditions, matchType, name, action, actionValue, actionOptionsJSON)
|
||||||
|
// A checkbox only ever appears in the submitted form when checked — its
|
||||||
|
// absence means "unchecked", not "field not present", so this can't be
|
||||||
|
// folded into UpdateRuleMulti's own column list the way the others are.
|
||||||
|
if err == nil {
|
||||||
|
err = a.DB.SetRuleActive(ruleID, mbox.ID, r.FormValue("is_active") != "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = a.DB.CreateRuleMulti(mbox.ID, priority, conditions, matchType, name, action, actionValue, actionOptionsJSON)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "Error saving rule")
|
||||||
|
http.Redirect(w, r, redirectTarget, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlash(w, "success", "Rule saved")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) webmailToggleRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
ruleID := int64(atoi(r.PathValue("rule_id")))
|
||||||
|
rule, _ := a.DB.GetRuleByID(mbox.ID, ruleID)
|
||||||
|
if rule == nil {
|
||||||
|
setFlash(w, "error", "Rule not found")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.DB.CreateRuleMulti(mbox.ID, priority, conditions, matchType, action, actionValue); err != nil {
|
if err := a.DB.SetRuleActive(ruleID, mbox.ID, !rule.IsActive); err != nil {
|
||||||
setFlash(w, "error", "Error creating rule")
|
setFlash(w, "error", "Error updating rule")
|
||||||
|
} else if rule.IsActive {
|
||||||
|
setFlash(w, "success", "Rule disabled")
|
||||||
} else {
|
} else {
|
||||||
setFlash(w, "success", "Rule added")
|
setFlash(w, "success", "Rule enabled")
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestWebmailRulesAddAndRemove(t *testing.T) {
|
|||||||
cookie := webmailLoginSession(t, app, mailboxID)
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=newsletter&action=move_to_folder&action_value=Newsletters"
|
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=newsletter&action=move_to_folder&action_value=Newsletters"
|
||||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/add", strings.NewReader(form))
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -80,7 +80,7 @@ func TestWebmailRulesAddMultiCondition(t *testing.T) {
|
|||||||
"action": {"mark_as_spam"},
|
"action": {"mark_as_spam"},
|
||||||
"action_value": {""},
|
"action_value": {""},
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/add", strings.NewReader(form.Encode()))
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -113,7 +113,7 @@ func TestWebmailRulesRejectsInvalidInput(t *testing.T) {
|
|||||||
|
|
||||||
// move_to_folder with no destination folder named.
|
// move_to_folder with no destination folder named.
|
||||||
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=x&action=move_to_folder&action_value="
|
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=x&action=move_to_folder&action_value="
|
||||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/add", strings.NewReader(form))
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -127,6 +127,143 @@ func TestWebmailRulesRejectsInvalidInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWebmailRulesEditUpdatesInPlace confirms submitting the builder form with an
|
||||||
|
// existing rule_id updates that rule (via UpdateRuleMulti) rather than creating a
|
||||||
|
// second one — the same form/endpoint (/rules/save) serves both add and edit.
|
||||||
|
func TestWebmailRulesEditUpdatesInPlace(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "ruler4@example.com", domains[0].ID, "ruler-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "old", "delete", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"rule_id": {strconv.FormatInt(ruleID, 10)},
|
||||||
|
"name": {"Renamed"},
|
||||||
|
"priority": {"5"},
|
||||||
|
"match_type": {"all"},
|
||||||
|
"condition_field": {"subject"},
|
||||||
|
"condition_op": {"contains"},
|
||||||
|
"condition_value": {"new"},
|
||||||
|
"action": {"mark_read"},
|
||||||
|
"action_value": {""},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("edit rule: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := app.DB.ListRulesForMailbox(mailboxID)
|
||||||
|
if err != nil || len(rules) != 1 {
|
||||||
|
t.Fatalf("expected still exactly 1 rule (updated, not duplicated), got %d (err=%v)", len(rules), err)
|
||||||
|
}
|
||||||
|
if rules[0].ID != ruleID || rules[0].Name != "Renamed" || rules[0].Action != "mark_read" || rules[0].ConditionValue != "new" {
|
||||||
|
t.Errorf("unexpected rule after edit: %+v", rules[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailRulesEditPersistsEnabledCheckbox reproduces a live bug: the edit
|
||||||
|
// builder's "Enabled" checkbox visually reflected a disabled rule's state but
|
||||||
|
// submitting an edit never actually persisted it — UpdateRuleMulti's column list
|
||||||
|
// never included is_active, so editing (with or without touching the checkbox) never
|
||||||
|
// changed enabled/disabled state at all, only the separate toggle button did.
|
||||||
|
func TestWebmailRulesEditPersistsEnabledCheckbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "ruler6@example.com", domains[0].ID, "ruler-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "x", "delete", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.DB.SetRuleActive(ruleID, mailboxID, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit the disabled rule with the "Enabled" checkbox checked (is_active=1 present
|
||||||
|
// in the form) — should re-enable it.
|
||||||
|
form := url.Values{
|
||||||
|
"rule_id": {strconv.FormatInt(ruleID, 10)}, "priority": {"0"}, "match_type": {"all"},
|
||||||
|
"condition_field": {"subject"}, "condition_op": {"contains"}, "condition_value": {"x"},
|
||||||
|
"action": {"delete"}, "action_value": {""}, "is_active": {"1"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("edit rule: status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
rule, err := app.DB.GetRuleByID(mailboxID, ruleID)
|
||||||
|
if err != nil || rule == nil || !rule.IsActive {
|
||||||
|
t.Fatalf("expected rule re-enabled via edit form's checkbox, got %+v (err=%v)", rule, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit again with the checkbox omitted entirely (as a real unchecked <input
|
||||||
|
// type=checkbox> submits) — should disable it.
|
||||||
|
form.Del("is_active")
|
||||||
|
req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/save", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("edit rule: status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
rule, err = app.DB.GetRuleByID(mailboxID, ruleID)
|
||||||
|
if err != nil || rule == nil || rule.IsActive {
|
||||||
|
t.Fatalf("expected rule disabled via edit form's omitted checkbox, got %+v (err=%v)", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebmailRulesToggleFlipsActive confirms the quick enable/disable toggle button
|
||||||
|
// flips is_active without needing the full edit form.
|
||||||
|
func TestWebmailRulesToggleFlipsActive(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "ruler5@example.com", domains[0].ID, "ruler-password-1!")
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
ruleID, err := app.DB.CreateRule(mailboxID, 0, "subject", "contains", "x", "delete", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle := func() {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(ruleID, 10)+"/toggle", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("toggle: status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle()
|
||||||
|
rules, _ := app.DB.ListRulesForMailbox(mailboxID)
|
||||||
|
if rules[0].IsActive {
|
||||||
|
t.Fatalf("expected rule disabled after first toggle, got IsActive=true")
|
||||||
|
}
|
||||||
|
toggle()
|
||||||
|
rules, _ = app.DB.ListRulesForMailbox(mailboxID)
|
||||||
|
if !rules[0].IsActive {
|
||||||
|
t.Fatalf("expected rule re-enabled after second toggle, got IsActive=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebmailRulesScopedToOwnMailbox confirms one mailbox owner can't remove another
|
// TestWebmailRulesScopedToOwnMailbox confirms one mailbox owner can't remove another
|
||||||
// mailbox's rule by guessing its ID.
|
// mailbox's rule by guessing its ID.
|
||||||
func TestWebmailRulesScopedToOwnMailbox(t *testing.T) {
|
func TestWebmailRulesScopedToOwnMailbox(t *testing.T) {
|
||||||
|
|||||||
@@ -3,33 +3,37 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"mailgoserver/internal/db"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// webmailSignaturesPage lists a mailbox's saved signatures and the add/edit form.
|
// webmailSignaturesPage lists a mailbox's saved signatures — add/edit happens in a
|
||||||
|
// popup (webmail_settings_chrome.html's signature modal), not on this page itself.
|
||||||
func (a *App) webmailSignaturesPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailSignaturesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
signatures, err := a.DB.ListSignatures(mbox.ID)
|
signatures, err := a.DB.ListSignatures(mbox.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
setFlash(w, "error", "Error loading signatures")
|
setFlash(w, "error", "Error loading signatures")
|
||||||
}
|
}
|
||||||
|
aliasDefaults, err := a.DB.ListSignatureAliasDefaults(mbox.ID)
|
||||||
// ?edit=<id> loads an existing signature into the form instead of a blank one.
|
if err != nil {
|
||||||
var editing *db.MailboxSignature
|
setFlash(w, "error", "Error loading alias defaults")
|
||||||
if idStr := r.URL.Query().Get("edit"); idStr != "" {
|
|
||||||
editing, _ = a.DB.GetSignatureByID(mbox.ID, int64(atoi(idStr)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.render(w, r, "webmail_signatures.html", M{
|
a.render(w, r, "webmail_signatures.html", M{
|
||||||
"mailbox": mbox, "signatures": signatures, "editing": editing,
|
"mailbox": mbox, "signatures": signatures, "send_as_options": a.sendAsAddresses(mbox),
|
||||||
"flashes": popFlashes(w, r),
|
"alias_defaults": aliasDefaults,
|
||||||
|
"flashes": popFlashes(w, r), "active_section": "signatures",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// webmailSignatureSave creates a new signature, or updates one when id (a hidden
|
// webmailSignatureSave creates a new signature, or updates one when id (a hidden
|
||||||
// form field, not a path segment — this is a single form reused for add and edit) is
|
// form field, not a path segment — this is a single form reused for add and edit) is
|
||||||
// set.
|
// set. default_for_email/default_new/default_reply (all optional) additionally set
|
||||||
|
// this signature as a specific send-as alias's default — see
|
||||||
|
// db.SetSignatureAliasDefault. The mailbox's own primary-address default is set
|
||||||
|
// separately, via the existing per-row "Use for new"/"Use for reply/forward" buttons
|
||||||
|
// (webmailSignatureSetDefault) — deliberately not folded into this form, since that
|
||||||
|
// control already works and scoping it here would just be two ways to do the same
|
||||||
|
// thing for that one address.
|
||||||
func (a *App) webmailSignatureSave(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailSignatureSave(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -50,14 +54,25 @@ func (a *App) webmailSignatureSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id != 0 {
|
if id != 0 {
|
||||||
err = a.DB.UpdateSignature(mbox.ID, id, name, contentHTML)
|
err = a.DB.UpdateSignature(mbox.ID, id, name, contentHTML)
|
||||||
} else {
|
} else {
|
||||||
_, err = a.DB.CreateSignature(mbox.ID, name, contentHTML)
|
id, err = a.DB.CreateSignature(mbox.ID, name, contentHTML)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Logger.Error("save signature for mailbox %d: %v", mbox.ID, err)
|
a.Logger.Error("save signature for mailbox %d: %v", mbox.ID, err)
|
||||||
setFlash(w, "error", "Could not save the signature")
|
setFlash(w, "error", "Could not save the signature")
|
||||||
} else {
|
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
||||||
setFlash(w, "success", "Signature saved")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if forEmail := strings.TrimSpace(r.FormValue("default_for_email")); forEmail != "" {
|
||||||
|
if r.FormValue("default_new") != "" {
|
||||||
|
a.DB.SetSignatureAliasDefault(mbox.ID, id, forEmail, false)
|
||||||
|
}
|
||||||
|
if r.FormValue("default_reply") != "" {
|
||||||
|
a.DB.SetSignatureAliasDefault(mbox.ID, id, forEmail, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlash(w, "success", "Signature saved")
|
||||||
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +89,8 @@ func (a *App) webmailSignatureDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// webmailSignatureSetDefault marks a signature (or, when id=0, clears the flag
|
// webmailSignatureSetDefault marks a signature (or, when id=0, clears the flag
|
||||||
// entirely) as the mailbox's default for new messages or for reply/forward, per the
|
// entirely) as the mailbox's default for new messages or for reply/forward, per the
|
||||||
// "which" form field ("new" or "reply").
|
// "which" form field ("new" or "reply"). This is the mailbox's own primary-address
|
||||||
|
// default — see webmailSignatureSetAliasDefault for a specific send-as alias's.
|
||||||
func (a *App) webmailSignatureSetDefault(w http.ResponseWriter, r *http.Request) {
|
func (a *App) webmailSignatureSetDefault(w http.ResponseWriter, r *http.Request) {
|
||||||
mbox := mailboxFromContext(r)
|
mbox := mailboxFromContext(r)
|
||||||
id := int64(atoi(r.PathValue("id")))
|
id := int64(atoi(r.PathValue("id")))
|
||||||
@@ -86,3 +102,23 @@ func (a *App) webmailSignatureSetDefault(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webmailSignatureRemoveAliasDefault clears one alias's default-for-new or
|
||||||
|
// default-for-reply override (the little x on a signature's alias-scoped badge),
|
||||||
|
// falling back to the mailbox's own primary default for that alias again.
|
||||||
|
func (a *App) webmailSignatureRemoveAliasDefault(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mbox := mailboxFromContext(r)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
setFlash(w, "error", "Invalid form data")
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forEmail := strings.TrimSpace(r.FormValue("for_email"))
|
||||||
|
forReply := r.FormValue("for_reply") != ""
|
||||||
|
if err := a.DB.ClearSignatureAliasDefault(mbox.ID, forEmail, forReply); err != nil {
|
||||||
|
setFlash(w, "error", "Could not remove the alias default")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Alias default removed")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, MailboxPrefix+"/signatures", http.StatusFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func TestWebmailSignatureCreateEditDeleteAndDefaults(t *testing.T) {
|
|||||||
if !got.IsDefaultNew || !got.IsDefaultReply {
|
if !got.IsDefaultNew || !got.IsDefaultReply {
|
||||||
t.Fatalf("expected both defaults set, got %+v", got)
|
t.Fatalf("expected both defaults set, got %+v", got)
|
||||||
}
|
}
|
||||||
def, err := app.DB.GetDefaultSignature(mailboxID, false)
|
def, err := app.DB.GetDefaultSignature(mailboxID, false, "")
|
||||||
if err != nil || def == nil || def.ID != id {
|
if err != nil || def == nil || def.ID != id {
|
||||||
t.Fatalf("GetDefaultSignature(new) = %+v, err=%v", def, err)
|
t.Fatalf("GetDefaultSignature(new) = %+v, err=%v", def, err)
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,59 @@ func TestWebmailSignatureCreateEditDeleteAndDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWebmailSignatureSaveSetsAliasDefault confirms the "also set as default for a
|
||||||
|
// specific alias" fields on the add/edit form (default_for_email/default_new/
|
||||||
|
// default_reply) actually persist an alias-scoped default via
|
||||||
|
// SetSignatureAliasDefault, and that GetDefaultSignature picks it up for that alias
|
||||||
|
// specifically (not the mailbox's own primary address).
|
||||||
|
func TestWebmailSignatureSaveSetsAliasDefault(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, "sigalias@example.com", domains[0].ID, "sigalias-password-1!")
|
||||||
|
if _, err := app.DB.CreateAlias(mailboxID, "support@example.com", domains[0].ID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"id": {""}, "name": {"Support"}, "content_html": {"<p>Support team</p>"},
|
||||||
|
"default_for_email": {"support@example.com"}, "default_new": {"1"}, "default_reply": {"1"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/signatures/save", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("save: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs, err := app.DB.ListSignatures(mailboxID)
|
||||||
|
if err != nil || len(sigs) != 1 {
|
||||||
|
t.Fatalf("expected 1 signature, got %d (err=%v)", len(sigs), err)
|
||||||
|
}
|
||||||
|
sigID := sigs[0].ID
|
||||||
|
|
||||||
|
forNew, err := app.DB.GetDefaultSignature(mailboxID, false, "support@example.com")
|
||||||
|
if err != nil || forNew == nil || forNew.ID != sigID {
|
||||||
|
t.Fatalf("expected support@example.com's new-message default to be the saved signature, got %+v (err=%v)", forNew, err)
|
||||||
|
}
|
||||||
|
forReply, err := app.DB.GetDefaultSignature(mailboxID, true, "support@example.com")
|
||||||
|
if err != nil || forReply == nil || forReply.ID != sigID {
|
||||||
|
t.Fatalf("expected support@example.com's reply default to be the saved signature, got %+v (err=%v)", forReply, err)
|
||||||
|
}
|
||||||
|
// The mailbox's own primary address is untouched — alias defaults are additive,
|
||||||
|
// not a replacement for the mailbox-wide default.
|
||||||
|
primary, err := app.DB.GetDefaultSignature(mailboxID, false, "sigalias@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if primary != nil {
|
||||||
|
t.Fatalf("expected no default for the mailbox's own primary address, got %+v", primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebmailSignatureOnlyOneDefaultPerMailbox confirms setting a second signature as
|
// TestWebmailSignatureOnlyOneDefaultPerMailbox confirms setting a second signature as
|
||||||
// the default-for-new clears the flag from whichever one had it before.
|
// the default-for-new clears the flag from whichever one had it before.
|
||||||
func TestWebmailSignatureOnlyOneDefaultPerMailbox(t *testing.T) {
|
func TestWebmailSignatureOnlyOneDefaultPerMailbox(t *testing.T) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func (a *App) webmailCertsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"pgp_unlocked": pgpUnlocked,
|
"pgp_unlocked": pgpUnlocked,
|
||||||
"pgp_contacts": pgpContacts,
|
"pgp_contacts": pgpContacts,
|
||||||
"flashes": popFlashes(w, r),
|
"flashes": popFlashes(w, r),
|
||||||
|
"active_section": "certs",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,12 +183,20 @@ func (a *App) Mux() *http.ServeMux {
|
|||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/order", a.webmailSetFolderOrder)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/order", a.webmailSetFolderOrder)
|
||||||
|
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/rules", a.webmailRulesList)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/rules", a.webmailRulesList)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/add", a.webmailAddRule)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/save", a.webmailSaveRule)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/toggle", a.webmailToggleRule)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/remove", a.webmailRemoveRule)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/remove", a.webmailRemoveRule)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/signatures", a.webmailSignaturesPage)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/signatures", a.webmailSignaturesPage)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/save", a.webmailSignatureSave)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/save", a.webmailSignatureSave)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/delete", a.webmailSignatureDelete)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/delete", a.webmailSignatureDelete)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/default", a.webmailSignatureSetDefault)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/{id}/default", a.webmailSignatureSetDefault)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/signatures/alias-default/remove", a.webmailSignatureRemoveAliasDefault)
|
||||||
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/contacts", a.webmailContactsPage)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/contacts/save", a.webmailContactSave)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/contacts/{id}/delete", a.webmailContactDelete)
|
||||||
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/blocklist", a.webmailBlocklistPage)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/blocklist/add", a.webmailBlocklistAdd)
|
||||||
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/blocklist/{id}/remove", a.webmailBlocklistRemove)
|
||||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/certs", a.webmailCertsPage)
|
webmailMux.HandleFunc("GET "+MailboxPrefix+"/certs", a.webmailCertsPage)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/generate", a.webmailSMIMEGenerate)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/generate", a.webmailSMIMEGenerate)
|
||||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/import", a.webmailSMIMEImport)
|
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/import", a.webmailSMIMEImport)
|
||||||
|
|||||||
Reference in New Issue
Block a user