// Package filter evaluates deterministic, local-only Usenet filters. package filter import ( "fmt" "path" "regexp" "strings" "time" ) type Field string const ( FieldAny Field = "any" FieldFrom Field = "from" FieldSubject Field = "subject" FieldNewsgroups Field = "newsgroups" FieldMessageID Field = "message-id" FieldReferences Field = "references" FieldDate Field = "date" FieldBody Field = "body" FieldHeader Field = "header" FieldVFaceHash Field = "vface-hash" FieldRead Field = "read" ) type Operator string const ( OperatorContains Operator = "contains" OperatorExact Operator = "exact" OperatorRegexp Operator = "regexp" OperatorGlob Operator = "glob" ) type Action string const ( ActionKeep Action = "keep" ActionHide Action = "hide" ActionMarkRead Action = "mark-read" ActionHighlight Action = "highlight" ActionTag Action = "tag" ActionMuteThread Action = "mute-thread" ) type Rule struct { ID string `json:"id"` Enabled bool `json:"enabled"` Field Field `json:"field"` Header string `json:"header,omitempty"` Operator Operator `json:"operator"` Pattern string `json:"pattern"` Action Action `json:"action"` Tag string `json:"tag,omitempty"` Reason string `json:"reason,omitempty"` } type Article struct { From string Subject string Newsgroups string MessageID string References string Date string Body string Read bool Headers map[string]string } type Result struct { Hidden bool MarkRead bool Highlight bool MuteThread bool Tags []string MatchedRules []string } type InvalidRuleError struct{ Message string } func (e *InvalidRuleError) Error() string { return e.Message } func Evaluate(article Article, rules []Rule) (Result, error) { var result Result for _, rule := range rules { if !rule.Enabled { continue } if err := validateRule(rule); err != nil { return Result{}, err } values := fieldValues(article, rule) matched, err := match(values, rule.Operator, rule.Pattern) if err != nil { return Result{}, fmt.Errorf("rule %q: %w", rule.ID, err) } if !matched { continue } result.MatchedRules = append(result.MatchedRules, rule.ID) switch rule.Action { case ActionHide: result.Hidden = true case ActionMarkRead: result.MarkRead = true case ActionHighlight: result.Highlight = true case ActionTag: if rule.Tag != "" && !contains(result.Tags, rule.Tag) { result.Tags = append(result.Tags, rule.Tag) } case ActionMuteThread: result.MuteThread = true case ActionKeep: default: return Result{}, &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)} } } return result, nil } func validateRule(rule Rule) error { if strings.TrimSpace(rule.Pattern) == "" { return &InvalidRuleError{Message: fmt.Sprintf("rule %q has an empty pattern", rule.ID)} } switch rule.Field { case FieldAny, FieldFrom, FieldSubject, FieldNewsgroups, FieldMessageID, FieldReferences, FieldDate, FieldBody, FieldHeader, FieldVFaceHash, FieldRead: default: return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported field %q", rule.ID, rule.Field)} } if rule.Field == FieldHeader && strings.TrimSpace(rule.Header) == "" { return &InvalidRuleError{Message: fmt.Sprintf("rule %q needs a header name", rule.ID)} } switch rule.Operator { case OperatorContains, OperatorExact, OperatorRegexp, OperatorGlob: default: return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported operator %q", rule.ID, rule.Operator)} } switch rule.Action { case ActionKeep, ActionHide, ActionMarkRead, ActionHighlight, ActionTag, ActionMuteThread: default: return &InvalidRuleError{Message: fmt.Sprintf("rule %q has unsupported action %q", rule.ID, rule.Action)} } return nil } func fieldValues(article Article, rule Rule) []string { if rule.Field == FieldAny { return []string{article.From, article.Subject, article.Newsgroups, article.MessageID, article.References, article.Date, article.Body, vfaceValue(article.Headers)} } if rule.Field == FieldHeader { for key, value := range article.Headers { if strings.EqualFold(key, rule.Header) { return []string{value} } } return nil } switch rule.Field { case FieldFrom: return []string{article.From} case FieldSubject: return []string{article.Subject} case FieldNewsgroups: return []string{article.Newsgroups} case FieldMessageID: return []string{article.MessageID} case FieldReferences: return []string{article.References} case FieldDate: return []string{article.Date} case FieldBody: return []string{article.Body} case FieldVFaceHash: return []string{vfaceValue(article.Headers)} case FieldRead: return []string{fmt.Sprintf("%t", article.Read)} default: return nil } } func vfaceValue(headers map[string]string) string { for _, key := range []string{"Identity-Hash", "X-VFace-Hash", "X-VFace-PNG-SHA256"} { for header, value := range headers { if strings.EqualFold(header, key) { return value } } } return "" } func match(values []string, operator Operator, pattern string) (bool, error) { for _, value := range values { switch operator { case OperatorContains: if strings.Contains(strings.ToLower(value), strings.ToLower(pattern)) { return true, nil } case OperatorExact: if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(pattern)) { return true, nil } case OperatorRegexp: re, err := regexp.Compile(pattern) if err != nil { return false, err } if re.MatchString(value) { return true, nil } case OperatorGlob: matched, err := path.Match(strings.ToLower(pattern), strings.ToLower(value)) if err != nil { return false, err } if matched { return true, nil } } } return false, nil } func contains(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false } // ParseDate is kept here so callers can validate date filters without adding // parser logic to the UI. It accepts the common RFC 5322 form and RFC3339. func ParseDate(value string) (time.Time, error) { if parsed, err := time.Parse(time.RFC1123Z, value); err == nil { return parsed, nil } return time.Parse(time.RFC3339, value) }