]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Add combinator option for multimap selector rules 5766/head
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 27 Nov 2025 15:37:47 +0000 (15:37 +0000)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 27 Nov 2025 15:37:47 +0000 (15:37 +0000)
This change adds support for structured data output from selectors in
multimap rules. Previously, selectors always produced concatenated
strings which made it impossible to send structured JSON data to
external map services.

New 'combinator' option for selector-type multimap rules:
- 'string' (default): concatenate results with delimiter (existing behavior)
- 'array': flatten all results into a flat array
- 'object': convert pairs of selectors into key-value object

Example configuration for external JSON API:
  multimap {
    MY_EXTERNAL_CHECK {
      type = "selector";
      selector = "id('from');from('smtp'):addr;id('ip');ip";
      combinator = "object";  # produces {"from": "...", "ip": "..."}
      map = {
        external = true;
        backend = "http://api.example.com/check";
        method = "body";
        encode = "json";
      };
    }
  }

Changes:
- lua_selectors: Added combinator registry and helper functions
  - get_combinator(name): returns combinator function by name
  - list_combinators(): returns available combinator names
  - create_selector_closure_with_combinator(): creates closure with named combinator
- multimap: Added 'combinator' option support for selector and redis+selector maps

lualib/lua_selectors/init.lua
src/plugins/lua/multimap.lua

index 5fcdb3833853224194014405c3d1752662f8345b..bfb012a692d4bcb3074edda782b370385db2c915 100644 (file)
@@ -662,6 +662,61 @@ exports.add_map = function(name, map)
   end
 end
 
+-- Combinator functions registry for use with external maps and other consumers
+-- that need structured data instead of plain strings
+local combinators = {
+  -- Default: concatenate all selector results into a single string
+  string = exports.combine_selectors,
+  -- Flatten all results into a flat array
+  array = exports.flatten_selectors,
+  -- Convert pairs of selectors into key-value object (use `id` extractor for keys)
+  object = exports.kv_table_from_pairs,
+}
+
+--[[[
+-- @function lua_selectors.get_combinator(name)
+-- Returns a combinator function by name
+-- @param {string} name combinator name: 'string', 'array', or 'object'
+-- @return {function} combinator function or nil if not found
+--]]
+exports.get_combinator = function(name)
+  return combinators[name]
+end
+
+--[[[
+-- @function lua_selectors.list_combinators()
+-- Returns list of available combinator names
+-- @return {table} array of combinator names
+--]]
+exports.list_combinators = function()
+  local res = {}
+  for k, _ in pairs(combinators) do
+    table.insert(res, k)
+  end
+  return res
+end
+
+--[[[
+-- @function lua_selectors.create_selector_closure_with_combinator(cfg, selector_str, delimiter, combinator_name)
+-- Creates a closure from a string selector using named combinator
+-- @param {rspamd_config} cfg rspamd config object
+-- @param {string} selector_str selector string to parse
+-- @param {string} delimiter delimiter for combining results (used by some combinators)
+-- @param {string} combinator_name name of combinator: 'string', 'array', or 'object'
+-- @return {function} closure that processes selector on task, or nil on error
+--]]
+exports.create_selector_closure_with_combinator = function(cfg, selector_str, delimiter, combinator_name)
+  local combinator_fn = combinators[combinator_name or 'string']
+
+  if not combinator_fn then
+    logger.errx(cfg, 'unknown combinator: %s, available: %s',
+        combinator_name, table.concat(exports.list_combinators(), ', '))
+    return nil
+  end
+
+  return exports.create_selector_closure_fn(cfg, cfg, selector_str, delimiter, combinator_fn)
+end
+
 -- Publish log target
 exports.M = M
 
index 1b186ef7f9f776b4114a0809092eb9ca41c9e7a8..4510d00178c47d4ff76a90bb1034279c16637cf2 100644 (file)
@@ -2010,13 +2010,32 @@ local function add_multimap_rule(key, newrule)
       rspamd_logger.errx(rspamd_config, 'selector map requires selector definition')
       return nil
     else
-      local selector = lua_selectors.create_selector_closure(
-        rspamd_config, newrule['selector'], newrule['delimiter'] or "")
+      local selector
+      local combinator = newrule['combinator']
+
+      if combinator then
+        -- Use named combinator for structured output (array, object, string)
+        selector = lua_selectors.create_selector_closure_with_combinator(
+          rspamd_config, newrule['selector'], newrule['delimiter'] or "", combinator)
+
+        if not selector then
+          rspamd_logger.errx(rspamd_config, 'selector map has invalid selector or combinator: "%s", combinator: %s, symbol: %s',
+            newrule['selector'], combinator, newrule['symbol'])
+          return nil
+        end
 
-      if not selector then
-        rspamd_logger.errx(rspamd_config, 'selector map has invalid selector: "%s", symbol: %s',
-          newrule['selector'], newrule['symbol'])
-        return nil
+        lua_util.debugm(N, rspamd_config, 'created selector with combinator %s for rule %s',
+          combinator, newrule['symbol'])
+      else
+        -- Default behavior: use combine_selectors (string output)
+        selector = lua_selectors.create_selector_closure(
+          rspamd_config, newrule['selector'], newrule['delimiter'] or "")
+
+        if not selector then
+          rspamd_logger.errx(rspamd_config, 'selector map has invalid selector: "%s", symbol: %s',
+            newrule['selector'], newrule['symbol'])
+          return nil
+        end
       end
 
       newrule.selector = selector
@@ -2044,13 +2063,30 @@ local function add_multimap_rule(key, newrule)
     end
 
     local selector_str = string.match(newrule['map'], '^redis%+selector://(.*)$')
-    local selector = lua_selectors.create_selector_closure(
-      rspamd_config, selector_str, newrule['delimiter'] or "")
+    local selector
+    local combinator = newrule['combinator']
 
-    if not selector then
-      rspamd_logger.errx(rspamd_config, 'redis selector map has invalid selector: "%s", symbol: %s',
-        selector_str, newrule['symbol'])
-      return nil
+    if combinator then
+      selector = lua_selectors.create_selector_closure_with_combinator(
+        rspamd_config, selector_str, newrule['delimiter'] or "", combinator)
+
+      if not selector then
+        rspamd_logger.errx(rspamd_config, 'redis selector map has invalid selector or combinator: "%s", combinator: %s, symbol: %s',
+          selector_str, combinator, newrule['symbol'])
+        return nil
+      end
+
+      lua_util.debugm(N, rspamd_config, 'created redis selector with combinator %s for rule %s',
+        combinator, newrule['symbol'])
+    else
+      selector = lua_selectors.create_selector_closure(
+        rspamd_config, selector_str, newrule['delimiter'] or "")
+
+      if not selector then
+        rspamd_logger.errx(rspamd_config, 'redis selector map has invalid selector: "%s", symbol: %s',
+          selector_str, newrule['symbol'])
+        return nil
+      end
     end
 
     newrule['redis_key'] = selector