2026-08-12 21:14:19 +01:00
|
|
|
package mailstore
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
import (
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"mailgoserver/internal/db"
|
|
|
|
|
)
|
2026-08-12 21:14:19 +01:00
|
|
|
|
|
|
|
|
// FilterAction is the outcome of evaluating a mailbox's filter rules against one
|
|
|
|
|
// incoming message.
|
|
|
|
|
type FilterAction struct {
|
|
|
|
|
Folder string // non-empty: store here instead of INBOX
|
|
|
|
|
MarkRead bool
|
|
|
|
|
Drop bool // don't store at all
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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").
|
|
|
|
|
// headers should have "from"/"to"/"subject" keys — rules run at delivery time, before
|
|
|
|
|
// the message is encrypted and stored, so real header values are available, not just
|
|
|
|
|
// the plaintext cache columns used for fast IMAP listing.
|
|
|
|
|
func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAction, error) {
|
|
|
|
|
rules, err := s.DB.ListRulesForMailbox(mailboxID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return FilterAction{}, err
|
|
|
|
|
}
|
|
|
|
|
for _, r := range rules {
|
|
|
|
|
if !r.IsActive {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-14 13:04:55 +01:00
|
|
|
if !ruleMatches(r, headers) {
|
2026-08-12 21:14:19 +01:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
switch r.Action {
|
|
|
|
|
case "move_to_folder":
|
|
|
|
|
return FilterAction{Folder: r.ActionValue}, nil
|
2026-08-14 13:04:55 +01:00
|
|
|
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
|
2026-08-12 21:14:19 +01:00
|
|
|
case "delete":
|
|
|
|
|
return FilterAction{Drop: true}, nil
|
|
|
|
|
case "mark_read":
|
|
|
|
|
return FilterAction{MarkRead: true}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return FilterAction{}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:04:55 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:14:19 +01:00
|
|
|
func matchCondition(op, value, target string) bool {
|
|
|
|
|
value = strings.ToLower(value)
|
|
|
|
|
target = strings.ToLower(target)
|
|
|
|
|
switch op {
|
|
|
|
|
case "contains":
|
|
|
|
|
return strings.Contains(value, target)
|
|
|
|
|
case "equals":
|
|
|
|
|
return value == target
|
|
|
|
|
case "starts_with":
|
|
|
|
|
return strings.HasPrefix(value, target)
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|