// User rules (FR-4). A rule overrides the JEV classification when its condition // matches the message. // // Syntax, one rule per line: // // => // // condition: ` ""`, joined with " and ". // // fields: from, to, subject, source, body // operators: contains, equals, starts_with, ends_with // // A single `*` matches every message (use it as the last line for a default). // effects: comma-separated `category=`, `importance=`, // `action=`. // // The first matching rule wins and overrides only the fields it names. The rules // editor that writes this text lands in Phase 6; the classifier already reads it. package classify import ( "fmt" "strings" ) // Rule is one parsed line. type Rule struct { Line int Raw string MatchAll bool Conditions []Condition Effects map[string]string } // Condition is one ` ""` term. type Condition struct { Field string Op string Value string } // ParseError records a line that could not be parsed. The rest of the document // still loads; the error is surfaced in logs so a typo is visible. type ParseError struct { Line int Text string Reason string } func (e ParseError) Error() string { return fmt.Sprintf("rules line %d: %s (%q)", e.Line, e.Reason, strings.TrimSpace(e.Text)) } // RuleSet is a parsed rules document. type RuleSet struct { Rules []Rule Errors []ParseError } // effectFields are the classification fields a rule may override. var effectFields = map[string]bool{ "category": true, "importance": true, "action": true, } var allowedImportance = map[string]bool{"low": true, "medium": true, "high": true} var allowedAction = map[string]bool{ "delete": true, "unsubscribe": true, "reply": true, "file": true, "keep": true, } // ParseRules parses a rules document. Malformed lines are collected in Errors and // skipped; parsing never fails outright, so one typo cannot disable the rules. func ParseRules(text string) RuleSet { var set RuleSet for i, raw := range strings.Split(text, "\n") { line := strings.TrimSpace(strings.TrimSuffix(raw, "\r")) if line == "" || strings.HasPrefix(line, "#") { continue } rule, err := parseRule(line) rule.Line = i + 1 rule.Raw = line if err != nil { set.Errors = append(set.Errors, ParseError{Line: i + 1, Text: raw, Reason: err.Error()}) continue } set.Rules = append(set.Rules, rule) } return set } func parseRule(line string) (Rule, error) { rule := Rule{Effects: map[string]string{}} left, right, ok := strings.Cut(line, "=>") if !ok { return rule, fmt.Errorf("missing =>") } left, right = strings.TrimSpace(left), strings.TrimSpace(right) if left == "" || right == "" { return rule, fmt.Errorf("empty condition or effect") } if left == "*" { rule.MatchAll = true } else { for _, part := range strings.Split(left, " and ") { cond, err := parseCondition(strings.TrimSpace(part)) if err != nil { return rule, err } rule.Conditions = append(rule.Conditions, cond) } } for _, part := range strings.Split(right, ",") { part = strings.TrimSpace(part) if part == "" { continue } field, value, err := parseEffect(part) if err != nil { return rule, err } rule.Effects[field] = value } if len(rule.Effects) == 0 { return rule, fmt.Errorf("no effects") } return rule, nil } // parseEffect accepts `field=value` and `field: value` to be forgiving of typing. func parseEffect(part string) (string, string, error) { field, value, ok := strings.Cut(part, "=") if !ok { field, value, ok = strings.Cut(part, ":") } if !ok { return "", "", fmt.Errorf("effect %q is not field=value", part) } field = strings.ToLower(strings.TrimSpace(field)) value = unquote(strings.TrimSpace(value)) if !effectFields[field] { return "", "", fmt.Errorf("unknown effect field %q", field) } if value == "" { return "", "", fmt.Errorf("effect %q has no value", field) } switch field { case "importance": value = strings.ToLower(value) if !allowedImportance[value] { return "", "", fmt.Errorf("importance %q must be low, medium, or high", value) } case "action": value = strings.ToLower(value) if !allowedAction[value] { return "", "", fmt.Errorf("action %q must be delete, unsubscribe, reply, file, or keep", value) } } return field, value, nil } func parseCondition(part string) (Condition, error) { fields := strings.Fields(part) if len(fields) < 3 { return Condition{}, fmt.Errorf("condition %q needs a field, an operator, and a value", part) } field := strings.ToLower(fields[0]) switch field { case "from", "to", "subject", "source", "body": default: return Condition{}, fmt.Errorf("unknown condition field %q", field) } op := strings.ToLower(fields[1]) switch op { case "contains", "equals", "starts_with", "ends_with": default: return Condition{}, fmt.Errorf("unknown operator %q", op) } value := unquote(strings.TrimSpace(strings.Join(fields[2:], " "))) if value == "" { return Condition{}, fmt.Errorf("condition %q has no value", part) } return Condition{Field: field, Op: op, Value: value}, nil } func unquote(v string) string { if len(v) >= 2 { if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') { return v[1 : len(v)-1] } } return v } // Match returns the effects of the first matching rule. It reports nil when no // rule matches. Field lookups are case-insensitive; values compare // case-insensitively too. func (rs RuleSet) Match(fields map[string]string) *Rule { normalized := make(map[string]string, len(fields)) for k, v := range fields { normalized[strings.ToLower(k)] = strings.ToLower(v) } for i := range rs.Rules { r := &rs.Rules[i] if r.MatchAll || allMatch(r.Conditions, normalized) { return r } } return nil } func allMatch(conds []Condition, fields map[string]string) bool { for _, c := range conds { if !matches(c, fields[c.Field]) { return false } } return true } func matches(c Condition, fieldValue string) bool { want := strings.ToLower(c.Value) switch c.Op { case "contains": return strings.Contains(fieldValue, want) case "equals": return fieldValue == want case "starts_with": return strings.HasPrefix(fieldValue, want) case "ends_with": return strings.HasSuffix(fieldValue, want) } return false }