package rules import ( "context" "encoding/json" "fmt" "log/slog" "strings" "github.com/jackc/pgx/v5/pgxpool" ) type Engine struct { db *pgxpool.Pool logger *slog.Logger } func NewEngine(db *pgxpool.Pool) *Engine { return &Engine{ db: db, logger: slog.Default().With("component", "rules-engine"), } } type Rule struct { ID string `json:"id"` Name string `json:"name"` Priority int `json:"priority"` Conditions []Condition `json:"conditions"` Actions []Action `json:"actions"` } type Condition struct { Field string `json:"field"` // from, to, subject, body, has_attachment Operator string `json:"operator"` // contains, equals, starts_with, ends_with, matches Value string `json:"value"` } type Action struct { Type string `json:"type"` // label, move, archive, delete, mark_read, forward, webhook Value string `json:"value"` // label name, folder name, email, webhook_id } type Message struct { ID string `json:"id"` From string `json:"from"` To []string `json:"to"` Subject string `json:"subject"` BodyText string `json:"body_text"` HasAttachments bool `json:"has_attachments"` } func (e *Engine) Evaluate(ctx context.Context, userID string, msg *Message) error { rows, err := e.db.Query(ctx, ` SELECT id, name, conditions, actions FROM mail_rules WHERE user_id = $1 AND is_active = true ORDER BY priority ASC `, userID) if err != nil { return fmt.Errorf("query rules: %w", err) } defer rows.Close() for rows.Next() { var ( ruleID string name string condJSON []byte actJSON []byte ) if err := rows.Scan(&ruleID, &name, &condJSON, &actJSON); err != nil { e.logger.Error("scan rule", "error", err) continue } var conditions []Condition var actions []Action json.Unmarshal(condJSON, &conditions) json.Unmarshal(actJSON, &actions) if matchesAll(conditions, msg) { e.logger.Info("rule matched", "rule_id", ruleID, "rule_name", name, "message_id", msg.ID) for _, action := range actions { if err := e.executeAction(ctx, action, msg); err != nil { e.logger.Error("action failed", "rule_id", ruleID, "action", action.Type, "error", err) } } // Increment match count e.db.Exec(ctx, `UPDATE mail_rules SET match_count = match_count + 1 WHERE id = $1`, ruleID) } } return nil } func matchesAll(conditions []Condition, msg *Message) bool { for _, cond := range conditions { if !matchCondition(cond, msg) { return false } } return true } func matchCondition(cond Condition, msg *Message) bool { var fieldValue string switch cond.Field { case "from": fieldValue = msg.From case "to": fieldValue = strings.Join(msg.To, ", ") case "subject": fieldValue = msg.Subject case "body": fieldValue = msg.BodyText case "has_attachment": if msg.HasAttachments { fieldValue = "true" } else { fieldValue = "false" } default: return false } fieldLower := strings.ToLower(fieldValue) valueLower := strings.ToLower(cond.Value) switch cond.Operator { case "contains": return strings.Contains(fieldLower, valueLower) case "equals": return fieldLower == valueLower case "starts_with": return strings.HasPrefix(fieldLower, valueLower) case "ends_with": return strings.HasSuffix(fieldLower, valueLower) case "not_contains": return !strings.Contains(fieldLower, valueLower) default: return false } } func (e *Engine) executeAction(ctx context.Context, action Action, msg *Message) error { switch action.Type { case "label": _, err := e.db.Exec(ctx, ` UPDATE messages SET labels = array_append(labels, $1), updated_at = NOW() WHERE id = $2 AND NOT ($1 = ANY(labels)) `, action.Value, msg.ID) return err case "move": _, err := e.db.Exec(ctx, ` UPDATE messages SET folder_id = ( SELECT id FROM mail_folders WHERE account_id = ( SELECT account_id FROM messages WHERE id = $2 ) AND name = $1 LIMIT 1 ), updated_at = NOW() WHERE id = $2 `, action.Value, msg.ID) return err case "archive": _, err := e.db.Exec(ctx, ` UPDATE messages SET flags = array_append(flags, '\Archive'), updated_at = NOW() WHERE id = $1 `, msg.ID) return err case "mark_read": _, err := e.db.Exec(ctx, ` UPDATE messages SET flags = array_append(flags, '\Seen'), updated_at = NOW() WHERE id = $1 AND NOT ('\Seen' = ANY(flags)) `, msg.ID) return err case "delete": _, err := e.db.Exec(ctx, ` UPDATE messages SET flags = array_append(flags, '\Deleted'), updated_at = NOW() WHERE id = $1 `, msg.ID) return err case "webhook": // Webhook execution is handled by the webhooks package return nil default: return fmt.Errorf("unknown action type: %s", action.Type) } }