MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+31 -2
View File
@@ -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)
+107
View File
@@ -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)
}
}
+16 -1
View File
@@ -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