package mailstore import ( "strings" "mailgoserver/internal/db" ) // 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 // 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 // first match's action (zero value if none match, meaning "store in INBOX, unread"). // headers should have "from"/"to"/"subject"/"body"/"has_attachment"/"recipient_type" // keys — rules run at delivery time, before the message is encrypted and stored, so // 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) { rules, err := s.DB.ListRulesForMailbox(mailboxID) if err != nil { return FilterAction{}, err } for _, r := range rules { if !r.IsActive { continue } 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 Junk folder score-based quarantine already delivers // into (see smtpserver/session.go) — from the mailbox owner's // perspective it's the same "goes to Junk" outcome either way. return FilterAction{Folder: "Junk"}, nil case "delete": return FilterAction{Drop: true}, nil case "mark_read": return FilterAction{MarkRead: true}, nil case "forward": return FilterAction{ForwardTo: r.ActionValue, KeepCopy: r.ActionOptions().KeepCopy}, nil } } 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 } // 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 { value = strings.ToLower(value) for _, t := range strings.Split(target, "\n") { t = strings.ToLower(strings.TrimSpace(t)) if t == "" { continue } switch op { case "contains": if strings.Contains(value, t) { return true } case "equals": if value == t { return true } case "starts_with": if strings.HasPrefix(value, t) { return true } } } return false }