MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package mailstore
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// FilterAction is the outcome of evaluating a mailbox's filter rules against one
|
||||
// incoming message.
|
||||
@@ -24,12 +28,17 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
|
||||
if !r.IsActive {
|
||||
continue
|
||||
}
|
||||
if !matchCondition(r.ConditionOp, headers[r.ConditionField], r.ConditionValue) {
|
||||
if !ruleMatches(r, headers) {
|
||||
continue
|
||||
}
|
||||
switch r.Action {
|
||||
case "move_to_folder":
|
||||
return FilterAction{Folder: r.ActionValue}, nil
|
||||
case "mark_as_spam":
|
||||
// Reuses the same Spam folder score-based quarantine already delivers
|
||||
// into (see smtpserver/session.go) — from the mailbox owner's
|
||||
// perspective it's the same "goes to Spam" outcome either way.
|
||||
return FilterAction{Folder: "Spam"}, nil
|
||||
case "delete":
|
||||
return FilterAction{Drop: true}, nil
|
||||
case "mark_read":
|
||||
@@ -39,6 +48,26 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
|
||||
return FilterAction{}, nil
|
||||
}
|
||||
|
||||
// ruleMatches combines a rule's conditions per its match type: "all" requires every
|
||||
// condition to match (AND), "any" requires at least one (OR).
|
||||
func ruleMatches(r db.MailboxFilterRule, headers map[string]string) bool {
|
||||
conditions, matchType := r.Conditions()
|
||||
if matchType == "any" {
|
||||
for _, c := range conditions {
|
||||
if matchCondition(c.Op, headers[c.Field], c.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, c := range conditions {
|
||||
if !matchCondition(c.Op, headers[c.Field], c.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCondition(op, value, target string) bool {
|
||||
value = strings.ToLower(value)
|
||||
target = strings.ToLower(target)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// TestApplyRulesMultiConditionAnd confirms an "all" (AND) rule only matches when
|
||||
// every condition matches.
|
||||
func TestApplyRulesMultiConditionAnd(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
conditions := []db.RuleCondition{
|
||||
{Field: "to", Op: "contains", Value: "sales"},
|
||||
{Field: "subject", Op: "contains", Value: "invoice"},
|
||||
}
|
||||
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "move_to_folder", "Invoices"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Matches "to" only — AND rule should not fire.
|
||||
action, err := s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Folder != "" {
|
||||
t.Fatalf("expected no match with only one AND condition satisfied, got folder=%q", action.Folder)
|
||||
}
|
||||
|
||||
// Matches both — AND rule should fire.
|
||||
action, err = s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "your invoice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Folder != "Invoices" {
|
||||
t.Fatalf("expected move to Invoices when both AND conditions match, got %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRulesMultiConditionOr confirms an "any" (OR) rule matches when at least
|
||||
// one condition matches.
|
||||
func TestApplyRulesMultiConditionOr(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
conditions := []db.RuleCondition{
|
||||
{Field: "from", Op: "contains", Value: "boss@work.example"},
|
||||
{Field: "subject", Op: "contains", Value: "urgent"},
|
||||
}
|
||||
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "any", "mark_read", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
action, err := s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "urgent: read me"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !action.MarkRead {
|
||||
t.Fatalf("expected OR rule to fire on subject match alone, got %+v", action)
|
||||
}
|
||||
|
||||
action, err = s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.MarkRead {
|
||||
t.Fatalf("expected OR rule not to fire when neither condition matches, got %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRulesMarkAsSpam confirms the mark_as_spam action routes into the Spam
|
||||
// folder, same as score-based quarantine.
|
||||
func TestApplyRulesMarkAsSpam(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "viagra"}}
|
||||
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{"subject": "cheap viagra now"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Folder != "Spam" {
|
||||
t.Fatalf("expected mark_as_spam to route into the Spam folder, got %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRulesLegacySingleConditionFallback confirms a rule row with an empty
|
||||
// ConditionsJSON (as any rule created before multi-condition support existed would
|
||||
// have) still evaluates correctly via the legacy condition_field/op/value columns.
|
||||
func TestApplyRulesLegacySingleConditionFallback(t *testing.T) {
|
||||
s, mailboxID := newTestMailbox(t, 1024*1024)
|
||||
// CreateRule (not CreateRuleMulti) still writes conditions_json today, so to
|
||||
// simulate genuinely old pre-migration data we insert directly with an empty
|
||||
// conditions_json, exactly as an old row would look on disk.
|
||||
if _, err := s.DB.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
|
||||
VALUES (?, 0, 'subject', 'contains', 'newsletter', 'delete', '')`, mailboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "weekly newsletter"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !action.Drop {
|
||||
t.Fatalf("expected the legacy single-condition rule to still match, got %+v", action)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// extractHeaderValue reads a single header out of raw without parsing the body — used
|
||||
// to compute StoreMessage's cached_to column cheaply (no MIME/multipart walk needed
|
||||
// just to cache a header for fast folder-listing display). Returns "" on any parse
|
||||
// failure or if the header is absent, never an error — this is a display convenience,
|
||||
// not something delivery should ever fail over.
|
||||
func extractHeaderValue(raw []byte, name string) string {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg.Header.Get(name)
|
||||
}
|
||||
|
||||
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
||||
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
||||
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
||||
@@ -51,7 +66,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
||||
return 0, err
|
||||
}
|
||||
|
||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, subject)
|
||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject)
|
||||
if err != nil {
|
||||
os.Remove(storagePath)
|
||||
return 0, err
|
||||
|
||||
Reference in New Issue
Block a user