]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Email aliases resolution and message classification
authorVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 3 Oct 2025 10:32:26 +0000 (11:32 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 3 Oct 2025 10:40:14 +0000 (11:40 +0100)
This commit adds comprehensive email alias handling and message direction
classification functionality.

Features added:

* [Feature] New lua_aliases library (lualib/lua_aliases.lua)
  - Parse Unix /etc/aliases format (postmaster: root, etc.)
  - Parse Postfix virtual aliases (user@domain -> target@domain)
  - Parse local domains lists
  - Recursive alias resolution with loop detection (max depth: 10)
  - Multiple storage backends: File, Map, CDB
  - Message classification: inbound/outbound/internal/forwarded
  - Gmail-specific rules (dots removal, plus-addressing)
  - Generic plus-addressing support for all domains

* [Feature] New aliases plugin (src/plugins/lua/aliases.lua)
  - Prefilter for early alias resolution and classification
  - Registers 13 symbols:
    - Classification: LOCAL_INBOUND, LOCAL_OUTBOUND, INTERNAL_MAIL
    - Aliases: ALIAS_RESOLVED, TAGGED_FROM, TAGGED_RCPT
    - Forwarding: FWD_GOOGLE, FWD_YANDEX, FWD_MAILRU, FWD_SRS,
                  FWD_SIEVE, FWD_CPANEL, FORWARDED
  - Stores classification in task cache for other plugins access

* [Rework] Email plus-aliases moved from rules/misc.lua
  - Old EMAIL_PLUS_ALIASES, TAGGED_FROM, TAGGED_RCPT removed
  - Functionality integrated into new aliases plugin
  - Enhanced with full alias resolution support

* [Rework] Forwarding detection moved from rules/forwarding.lua
  - All FWD_* symbols moved to aliases plugin
  - Executes in prefilter (correct order before classification)
  - Optimized: single pass instead of 7 separate callbacks
  - Same symbols preserved for backward compatibility

* [Conf] New configuration file conf/modules.d/aliases.conf
  - Disabled by default (enabled = false)
  - Full examples for all backend types
  - Configuration options documented

* [Test] Functional tests in 001_merged suite
  - 15 test cases covering all functionality
  - Tests for Unix aliases, virtual aliases, classification
  - Gmail/plus-addressing tests, forwarding tests
  - Edge cases and performance tests

Configuration example:

  # local.d/aliases.conf
  aliases {
    enabled = true;

    # System aliases
    system_aliases = "/etc/aliases";

    # Local domains
    local_domains = ["example.com", "mail.example.com"];

    # Options
    max_recursion_depth = 10;
    enable_gmail_rules = true;
    enable_plus_aliases = true;
  }

Usage in other plugins:

  local lua_aliases = require "lua_aliases"

  -- Get message classification
  local classification = task:cache_get('aliases_classification')
  if classification.direction == 'inbound' then
    -- Apply inbound rules
  end

BREAKING CHANGES:

  - rules/misc.lua: EMAIL_PLUS_ALIASES removed (use aliases plugin)
  - rules/forwarding.lua: all forwarding rules removed (use aliases plugin)
  - To enable old behavior without aliases plugin, uncomment code in rules/
  - Redis backend NOT supported (use CDB or Map instead)

Notes:

  - Module disabled by default, enable in local.d/aliases.conf
  - Uses task:cache_set() for storing classification (native Rspamd way)
  - Debug logging via lua_util.debugm (use debug_modules = ["lua_aliases", "aliases"])
  - Forwarding symbols now virtual (parent = ALIASES_CHECK)

15 files changed:
conf/modules.d/aliases.conf [new file with mode: 0644]
lualib/lua_aliases.lua [new file with mode: 0644]
rules/forwarding.lua
rules/misc.lua
src/plugins/lua/aliases.lua [new file with mode: 0644]
test/functional/cases/001_merged/360_aliases.robot [new file with mode: 0644]
test/functional/configs/maps/test_local_domains.map [new file with mode: 0644]
test/functional/configs/maps/test_unix_aliases.map [new file with mode: 0644]
test/functional/configs/maps/test_virtual_aliases.map [new file with mode: 0644]
test/functional/configs/merged-local.conf
test/functional/messages/aliases_inbound.eml [new file with mode: 0644]
test/functional/messages/aliases_internal.eml [new file with mode: 0644]
test/functional/messages/aliases_outbound.eml [new file with mode: 0644]
test/functional/messages/aliases_plus.eml [new file with mode: 0644]
test/functional/messages/aliases_simple.eml [new file with mode: 0644]

diff --git a/conf/modules.d/aliases.conf b/conf/modules.d/aliases.conf
new file mode 100644 (file)
index 0000000..dceb76d
--- /dev/null
@@ -0,0 +1,95 @@
+# Module: aliases
+#
+# Email aliases resolution and local domains management
+#
+# This module provides:
+# - Unix-style aliases resolution (/etc/aliases format)
+# - Virtual aliases (Postfix virtual format)
+# - Local domain detection
+# - Message classification (inbound/outbound/internal)
+# - Service-specific rules (Gmail, plus-aliases)
+# - Forwarding detection (moved from rules/forwarding.lua for correct ordering)
+#
+# IMPORTANT: This module includes forwarding detection functionality.
+# If you enable this module, you should DISABLE rules/forwarding.lua to avoid
+# duplicate symbol registration.
+#
+# Documentation: https://rspamd.com/doc/modules/aliases.html
+
+aliases {
+  # Enable/disable the module
+  #enabled = true;
+
+  # System aliases (Unix /etc/aliases format)
+  # Can be:
+  #  - Simple path: "/etc/aliases"
+  #  - Table with type: {type = "file"; path = "/etc/aliases"}
+  #  - Map: {type = "map"; url = "file:///etc/aliases"}
+  #  - CDB: {type = "cdb"; path = "/var/lib/rspamd/aliases.cdb"}
+  #
+  # system_aliases = "/etc/aliases";
+
+  # Virtual aliases (Postfix virtual format)
+  # Same backend options as system_aliases
+  #
+  # virtual_aliases = "/etc/postfix/virtual";
+
+  # Local domains (domains we handle locally)
+  # Can be:
+  #  - Array: ["example.com", "mail.example.com"]
+  #  - File path: "/etc/rspamd/local_domains.list"
+  #  - Table with backend config
+  #
+  # local_domains = [
+  #   "example.com",
+  #   "mail.example.com"
+  # ];
+  #
+  # Or load from file:
+  # local_domains = "/etc/rspamd/local_domains.list";
+  #
+  # Or use Postfix mydestination:
+  # local_domains = "/etc/postfix/mydestination";
+
+  # Rspamd-specific aliases (inline configuration)
+  # Format: "alias" = "target" or "alias" = ["target1", "target2"]
+  #
+  # rspamd_aliases = {
+  #   "support@example.com" = "support-team@example.com";
+  #   "sales@example.com" = ["user1@example.com", "user2@example.com"];
+  # }
+
+  # Resolution options
+  max_recursion_depth = 10;      # Maximum recursion depth for alias resolution
+  expand_multiple = true;        # Expand 1->N aliases (one alias to multiple targets)
+  track_chain = false;           # Track full resolution chain (for debugging)
+
+  # Apply alias resolution to which parts
+  apply_to_mime = true;          # Apply to MIME from/to headers
+  apply_to_smtp = true;          # Apply to SMTP envelope
+
+  # Service-specific rules
+  enable_gmail_rules = true;     # Enable Gmail-specific rules (dot removal, plus addressing)
+  enable_plus_aliases = true;    # Enable generic plus addressing (user+tag@domain)
+
+  # Symbol configuration
+  # These symbols are inserted based on message classification
+  symbol_local_inbound = "LOCAL_INBOUND";     # Mail from external to local domain
+  symbol_local_outbound = "LOCAL_OUTBOUND";   # Mail from local to external domain
+  symbol_internal_mail = "INTERNAL_MAIL";     # Mail from local to local
+  symbol_alias_resolved = "ALIAS_RESOLVED";   # Address was resolved through aliases
+  symbol_tagged_from = "TAGGED_FROM";         # From address has plus-tags
+  symbol_tagged_rcpt = "TAGGED_RCPT";         # Recipient has plus-tags
+
+  # Symbol scores (0.0 = informational only)
+  score_local_inbound = 0.0;
+  score_local_outbound = 0.0;
+  score_internal_mail = 0.0;
+  score_alias_resolved = 0.0;
+  score_tagged_from = 0.0;
+  score_tagged_rcpt = 0.0;
+
+  .include(try=true,priority=5) "${DBDIR}/dynamic/aliases.conf"
+  .include(try=true,priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/aliases.conf"
+  .include(try=true,priority=10) "$LOCAL_CONFDIR/override.d/aliases.conf"
+}
diff --git a/lualib/lua_aliases.lua b/lualib/lua_aliases.lua
new file mode 100644 (file)
index 0000000..f5a4679
--- /dev/null
@@ -0,0 +1,959 @@
+--[[
+Copyright (c) 2025, Vsevolod Stakhov <vsevolod@rspamd.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+]] --
+
+--[[[
+-- @module lua_aliases
+-- Email aliases resolution and local domains management
+--
+-- This module provides functionality for:
+-- - Parsing Unix-style aliases (/etc/aliases format)
+-- - Parsing virtual aliases (Postfix virtual format)
+-- - Resolving email addresses through alias chains
+-- - Detecting local vs external domains
+-- - Classifying message direction (inbound/outbound/internal)
+--]]
+
+local rspamd_logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local lua_maps = require "lua_maps"
+
+local N = "lua_aliases"
+
+local exports = {}
+
+--- Count elements in a table (including hash tables)
+-- @param tbl table to count
+-- @return number of elements
+local function table_length(tbl)
+  if not tbl then return 0 end
+  local count = 0
+  for _ in pairs(tbl) do
+    count = count + 1
+  end
+  return count
+end
+
+-- Module state
+local module_state = {
+  initialized = false,
+  config = nil,
+  local_domains = {},   -- Set of local domains or backend
+  unix_aliases = {},    -- Unix aliases map or backend
+  virtual_aliases = {}, -- Virtual aliases map or backend
+  rspamd_aliases = {},  -- Rspamd-specific aliases
+  cache = {},           -- Resolution cache
+  backends = {},        -- Storage backends instances
+}
+
+--[[ Backend abstraction ]] --
+
+--- Backend interface:
+-- backend:get(key) -> value or nil
+-- backend:type() -> string
+
+-- File backend (already implemented via parse_* functions)
+local FileBackend = {}
+FileBackend.__index = FileBackend
+
+function FileBackend.new(file_path, parser_func)
+  local self = setmetatable({}, FileBackend)
+  self.file_path = file_path
+  self.parser_func = parser_func
+  self.data = nil
+  self.loaded = false
+  return self
+end
+
+function FileBackend:load()
+  if not self.loaded then
+    self.data = self.parser_func(self.file_path)
+    self.loaded = true
+  end
+  return self.data ~= nil
+end
+
+function FileBackend:get(key)
+  if not self.loaded then
+    self:load()
+  end
+  return self.data and self.data[key:lower()]
+end
+
+function FileBackend:type()
+  return "file"
+end
+
+-- Map backend (using lua_maps)
+local MapBackend = {}
+MapBackend.__index = MapBackend
+
+function MapBackend.new(rspamd_config, map_config, map_type)
+  local self = setmetatable({}, MapBackend)
+  self.map_type = map_type or 'hash'
+
+  -- Create map using lua_maps
+  self.map = lua_maps.map_add_from_ucl(map_config, self.map_type,
+    'aliases map (' .. self.map_type .. ')')
+
+  if not self.map then
+    rspamd_logger.errx(rspamd_config, 'cannot create map from config: %s',
+      map_config)
+    return nil
+  end
+
+  return self
+end
+
+function MapBackend:get(key)
+  if not self.map then
+    return nil
+  end
+
+  local result = self.map:get_key(key:lower())
+  return result
+end
+
+function MapBackend:type()
+  return "map:" .. self.map_type
+end
+
+-- CDB backend (using existing CDB support)
+local CDBBackend = {}
+CDBBackend.__index = CDBBackend
+
+function CDBBackend.new(rspamd_config, cdb_path)
+  local self = setmetatable({}, CDBBackend)
+
+  -- Use lua_maps CDB support
+  local map_config = {
+    cdb = cdb_path,
+    external = true,
+  }
+
+  self.map = lua_maps.map_add_from_ucl(map_config, 'cdb', 'aliases cdb')
+  if not self.map then
+    rspamd_logger.errx(rspamd_config, 'cannot load CDB from %s', cdb_path)
+    return nil
+  end
+
+  return self
+end
+
+function CDBBackend:get(key)
+  if not self.map then
+    return nil
+  end
+
+  return self.map:get_key(key:lower())
+end
+
+function CDBBackend:type()
+  return "cdb"
+end
+
+-- Forward declarations for parser functions
+local parse_unix_aliases
+local parse_virtual_aliases
+local parse_local_domains
+
+--- Create backend from configuration
+-- @param rspamd_config rspamd config object
+-- @param config backend configuration (table or string)
+-- @param default_parser default parser for file backend
+-- @return backend instance or nil
+local function create_backend(rspamd_config, config, default_parser)
+  -- String path -> file backend
+  if type(config) == 'string' then
+    return FileBackend.new(config, default_parser)
+  end
+
+  -- Table configuration
+  if type(config) ~= 'table' then
+    return nil
+  end
+
+  local backend_type = config.type or 'file'
+
+  if backend_type == 'file' then
+    if config.path then
+      return FileBackend.new(config.path, default_parser)
+    end
+  elseif backend_type == 'map' then
+    if config.url or config.urls then
+      return MapBackend.new(rspamd_config, config, config.map_type or 'hash')
+    end
+  elseif backend_type == 'redis' then
+    rspamd_logger.errx(rspamd_config,
+      'Redis backend is not supported for aliases (requires async with task context). Use CDB or Map instead.')
+    return nil
+  elseif backend_type == 'cdb' then
+    if config.path then
+      return CDBBackend.new(rspamd_config, config.path)
+    end
+  end
+
+  rspamd_logger.errx(rspamd_config, 'unknown backend type or invalid config: %s', backend_type)
+  return nil
+end
+
+--- Initialize the module with configuration
+-- @param rspamd_config rspamd config object
+-- @param opts configuration options
+-- @return true on success
+local function init(rspamd_config, opts)
+  if module_state.initialized then
+    rspamd_logger.warnx(rspamd_config, 'lua_aliases already initialized')
+    return true
+  end
+
+  module_state.config = rspamd_config
+  opts = opts or {}
+
+  -- Load local domains
+  if opts.local_domains then
+    if type(opts.local_domains) == 'table' and not opts.local_domains.type then
+      -- Inline array of domains
+      for _, domain in ipairs(opts.local_domains) do
+        module_state.local_domains[domain:lower()] = true
+      end
+      rspamd_logger.infox(rspamd_config, 'loaded %s local domains from inline config',
+        #opts.local_domains)
+    else
+      -- Backend configuration
+      local backend = create_backend(rspamd_config, opts.local_domains, parse_local_domains)
+      if backend then
+        module_state.backends.local_domains = backend
+        rspamd_logger.infox(rspamd_config, 'initialized local domains backend: %s',
+          backend:type())
+
+        -- For file backend, load immediately
+        if backend.type and backend:type() == 'file' then
+          if backend:load() then
+            module_state.local_domains = backend.data
+            rspamd_logger.infox(rspamd_config, 'loaded %s local domains',
+              table_length(module_state.local_domains))
+          end
+        end
+      end
+    end
+  end
+
+  -- Load system aliases
+  if opts.system_aliases then
+    local backend = create_backend(rspamd_config, opts.system_aliases, parse_unix_aliases)
+    if backend then
+      module_state.backends.system_aliases = backend
+      rspamd_logger.infox(rspamd_config, 'initialized system aliases backend: %s',
+        backend:type())
+
+      -- For file backend, load immediately
+      if backend.type and backend:type() == 'file' then
+        if backend:load() then
+          module_state.unix_aliases = backend.data
+          rspamd_logger.infox(rspamd_config, 'loaded %s system aliases',
+            table_length(module_state.unix_aliases))
+        end
+      else
+        -- For other backends, keep reference
+        module_state.unix_aliases = backend
+      end
+    end
+  end
+
+  -- Load virtual aliases
+  if opts.virtual_aliases then
+    local backend = create_backend(rspamd_config, opts.virtual_aliases, parse_virtual_aliases)
+    if backend then
+      module_state.backends.virtual_aliases = backend
+      rspamd_logger.infox(rspamd_config, 'initialized virtual aliases backend: %s',
+        backend:type())
+
+      -- For file backend, load immediately
+      if backend.type and backend:type() == 'file' then
+        if backend:load() then
+          module_state.virtual_aliases = backend.data
+          rspamd_logger.infox(rspamd_config, 'loaded %s virtual aliases',
+            table_length(module_state.virtual_aliases))
+        end
+      else
+        -- For other backends, keep reference
+        module_state.virtual_aliases = backend
+      end
+    end
+  end
+
+  -- Load rspamd-specific aliases (always inline)
+  if opts.rspamd_aliases then
+    if type(opts.rspamd_aliases) == 'table' then
+      module_state.rspamd_aliases = opts.rspamd_aliases
+      rspamd_logger.infox(rspamd_config, 'loaded %s rspamd aliases from inline config',
+        table_length(opts.rspamd_aliases))
+    end
+  end
+
+  module_state.initialized = true
+  return true
+end
+exports.init = init
+
+--- Parse Unix-style aliases file (/etc/aliases format)
+-- Format:
+--   alias: target1, target2, ...
+--   # Comments
+--   continuation lines with backslash \
+--
+-- @param file_path path to aliases file
+-- @return table of aliases {alias -> {targets...}} or nil on error
+function parse_unix_aliases(file_path)
+  local aliases = {}
+
+  local f, err = io.open(file_path, 'r')
+  if not f then
+    if module_state.config then
+      rspamd_logger.warnx(module_state.config,
+        'cannot open aliases file %s: %s', file_path, err)
+    end
+    return nil
+  end
+
+  local current_line = ""
+  local line_num = 0
+
+  for line in f:lines() do
+    line_num = line_num + 1
+
+    -- Strip trailing whitespace
+    line = line:gsub('%s+$', '')
+
+    -- Handle line continuation
+    if line:match('\\$') then
+      current_line = current_line .. line:gsub('\\$', '')
+      goto continue
+    else
+      current_line = current_line .. line
+    end
+
+    -- Skip empty lines and comments
+    if current_line:match('^%s*$') or current_line:match('^%s*#') then
+      current_line = ""
+      goto continue
+    end
+
+    -- Parse alias line: "alias: target1, target2, ..."
+    local alias, targets = current_line:match('^%s*([^:]+):%s*(.*)$')
+
+    if alias and targets then
+      alias = alias:gsub('%s+$', ''):lower() -- Normalize alias
+
+      -- Split targets by comma
+      local target_list = {}
+      for target in targets:gmatch('[^,]+') do
+        target = target:gsub('^%s+', ''):gsub('%s+$', '') -- Trim
+
+        -- Skip special targets for now (:include:, |program, /file)
+        if not target:match('^:include:') and
+            not target:match('^|') and
+            not target:match('^/') then
+          -- Normalize target
+          target = target:lower()
+          -- Add domain if missing and it's not email format
+          if not target:match('@') then
+            -- It's a local user, we'll handle it later
+            table.insert(target_list, target)
+          else
+            table.insert(target_list, target)
+          end
+        else
+          if module_state.config then
+            rspamd_logger.debugm(N, module_state.config,
+              'skipping special target in %s:%d: %s', file_path, line_num, target)
+          end
+        end
+      end
+
+      if #target_list > 0 then
+        aliases[alias] = target_list
+      end
+    else
+      if module_state.config then
+        rspamd_logger.debugm(N, module_state.config,
+          'cannot parse line %d in %s: %s', line_num, file_path, current_line)
+      end
+    end
+
+    current_line = ""
+    :: continue ::
+  end
+
+  f:close()
+  return aliases
+end
+
+exports.parse_unix_aliases = parse_unix_aliases
+
+--- Parse virtual aliases file (Postfix virtual format)
+-- Format:
+--   user@domain.com target@domain.com
+--   @catchall.com   catchall@domain.com
+--
+-- @param file_path path to virtual aliases file
+-- @return table of aliases {source -> target} or nil on error
+function parse_virtual_aliases(file_path)
+  local aliases = {}
+
+  local f, err = io.open(file_path, 'r')
+  if not f then
+    if module_state.config then
+      rspamd_logger.warnx(module_state.config,
+        'cannot open virtual aliases file %s: %s', file_path, err)
+    end
+    return nil
+  end
+
+  local line_num = 0
+  for line in f:lines() do
+    line_num = line_num + 1
+
+    -- Skip empty lines and comments
+    if line:match('^%s*$') or line:match('^%s*#') then
+      goto continue
+    end
+
+    -- Parse: source target
+    local source, target = line:match('^%s*(%S+)%s+(%S+)')
+
+    if source and target then
+      source = source:lower()
+      target = target:lower()
+      aliases[source] = target
+    else
+      if module_state.config then
+        rspamd_logger.debugm(N, module_state.config,
+          'cannot parse line %d in %s: %s', line_num, file_path, line)
+      end
+    end
+
+    :: continue ::
+  end
+
+  f:close()
+  return aliases
+end
+
+exports.parse_virtual_aliases = parse_virtual_aliases
+
+--- Parse local domains file (one domain per line)
+-- Format:
+--   example.com
+--   mail.example.com
+--   # comments
+--
+-- @param file_path path to local domains file
+-- @return set of domains {domain -> true} or nil on error
+function parse_local_domains(file_path)
+  local domains = {}
+
+  local f, err = io.open(file_path, 'r')
+  if not f then
+    if module_state.config then
+      rspamd_logger.warnx(module_state.config,
+        'cannot open local domains file %s: %s', file_path, err)
+    end
+    return nil
+  end
+
+  for line in f:lines() do
+    -- Skip empty lines and comments
+    if not line:match('^%s*$') and not line:match('^%s*#') then
+      local domain = line:match('^%s*(%S+)')
+      if domain then
+        domains[domain:lower()] = true
+      end
+    end
+  end
+
+  f:close()
+  return domains
+end
+
+exports.parse_local_domains = parse_local_domains
+
+--- Check if a domain is local
+-- @param domain domain name to check
+-- @return true if domain is local, false otherwise
+local function is_local_domain(domain)
+  if not domain then
+    return false
+  end
+
+  domain = domain:lower()
+  local result = module_state.local_domains[domain] == true
+
+  lua_util.debugm(N, module_state.config,
+      'is_local_domain: domain=%s result=%s',
+      domain, result)
+
+  return result
+end
+exports.is_local_domain = is_local_domain
+
+--- Check if an email address is local
+-- @param addr email address (string or table with 'domain' field)
+-- @return true if address is in local domain, false otherwise
+local function is_local_address(addr)
+  local domain
+
+  if type(addr) == 'string' then
+    domain = addr:match('@([^@]+)$')
+  elseif type(addr) == 'table' and addr.domain then
+    domain = addr.domain
+  end
+
+  return is_local_domain(domain)
+end
+exports.is_local_address = is_local_address
+
+--- Apply service-specific alias rules (Gmail, plus-aliases)
+-- This replaces the old lua_util.remove_email_aliases() function
+-- @param email_addr email address table with user, domain, addr fields
+-- @return new_user, tags, new_domain or nil
+local function apply_service_rules(email_addr)
+  local function check_gmail_user(addr)
+    -- Remove all points
+    local no_dots_user = string.gsub(addr.user, '%.', '')
+    local cap, pluses = string.match(no_dots_user, '^([^%+][^%+]*)(%+.*)$')
+    if cap then
+      return cap, lua_util.str_split(pluses, '+'), nil
+    elseif no_dots_user ~= addr.user then
+      return no_dots_user, {}, nil
+    end
+
+    return nil
+  end
+
+  local function check_address(addr)
+    if addr.user then
+      local cap, pluses = string.match(addr.user, '^([^%+][^%+]*)(%+.*)$')
+      if cap then
+        return cap, lua_util.str_split(pluses, '+'), nil
+      end
+    end
+
+    return nil
+  end
+
+  local function check_gmail(addr)
+    local nu, tags, nd = check_gmail_user(addr)
+    if nu then
+      return nu, tags, nd
+    end
+    return nil
+  end
+
+  local function check_googlemail(addr)
+    local nd = 'gmail.com'
+    local nu, tags = check_gmail_user(addr)
+    if nu then
+      return nu, tags, nd
+    end
+    return nil, nil, nd
+  end
+
+  local specific_domains = {
+    ['gmail.com'] = check_gmail,
+    ['googlemail.com'] = check_googlemail,
+  }
+
+  if email_addr then
+    if email_addr.domain and specific_domains[email_addr.domain] then
+      local nu, tags, nd = specific_domains[email_addr.domain](email_addr)
+      if nu or nd then
+        return nu, tags, nd
+      end
+    else
+      local nu, tags, nd = check_address(email_addr)
+      if nu or nd then
+        return nu, tags, nd
+      end
+    end
+
+    return nil
+  end
+end
+exports.apply_service_rules = apply_service_rules
+
+--- Get value from backend or table
+-- @param source backend object or table
+-- @param key lookup key
+-- @return value or nil
+local function get_from_source(source, key)
+  if not source then
+    return nil
+  end
+
+  -- If it's a backend object with :get() method
+  if type(source) == 'table' and source.get then
+    return source:get(key)
+  end
+
+  -- Otherwise treat as plain table
+  return source[key]
+end
+
+--- Resolve one step of aliasing
+-- @param email_str normalized email string
+-- @return result (string, array of strings, or nil), rule_type
+local function resolve_one_step(email_str)
+  -- Check virtual aliases first
+  local virtual_result = get_from_source(module_state.virtual_aliases, email_str)
+  if virtual_result then
+    return virtual_result, 'virtual'
+  end
+
+  -- Check rspamd aliases
+  if module_state.rspamd_aliases[email_str] then
+    return module_state.rspamd_aliases[email_str], 'rspamd'
+  end
+
+  -- Check unix aliases (user part only)
+  local user = email_str:match('^([^@]+)@')
+  if user then
+    local unix_result = get_from_source(module_state.unix_aliases, user)
+    if unix_result then
+      -- Normalize result to always be array
+      if type(unix_result) == 'string' then
+        unix_result = { unix_result }
+      end
+
+      if type(unix_result) == 'table' and #unix_result > 0 then
+        -- Add domain to targets that don't have one
+        local domain = email_str:match('@([^@]+)$')
+        if domain then
+          local normalized = {}
+          for _, target in ipairs(unix_result) do
+            if not target:match('@') then
+              table.insert(normalized, target .. '@' .. domain)
+            else
+              table.insert(normalized, target)
+            end
+          end
+          return normalized, 'unix'
+        else
+          return unix_result, 'unix'
+        end
+      end
+    end
+  end
+
+  -- No alias found
+  return nil, nil
+end
+
+--- Resolve email address recursively with loop detection
+-- @param addr email address (string or table)
+-- @param opts options: max_depth, track_chain, expand_multiple
+-- @return canonical (string or array), chain, metadata
+local function resolve_address_recursive(addr, opts)
+  opts = opts or {}
+  local max_depth = opts.max_depth or 10
+  local track_chain = opts.track_chain
+  local expand_multiple = opts.expand_multiple
+
+  -- Convert to normalized form
+  local email_str
+  if type(addr) == 'string' then
+    email_str = addr:lower()
+  elseif type(addr) == 'table' and addr.addr then
+    email_str = addr.addr:lower()
+  else
+    return addr, nil, { error = 'invalid address format' }
+  end
+
+  -- Track visited addresses for loop detection
+  local visited = {}
+  local chain = track_chain and { email_str } or nil
+  local rules_applied = {}
+
+  --- Recursive resolve helper
+  -- @param current_addr current address to resolve
+  -- @param depth current recursion depth
+  -- @return array of canonical addresses
+  local function resolve_recursive(current_addr, depth)
+    -- Check depth limit
+    if depth > max_depth then
+      lua_util.debugm(N, module_state.config,
+        'max recursion depth %s reached for %s', max_depth, email_str)
+      return { current_addr }
+    end
+
+    -- Check for loops
+    if visited[current_addr] then
+      lua_util.debugm(N, module_state.config,
+        'alias loop detected for %s at %s', email_str, current_addr)
+      return { current_addr }
+    end
+
+    visited[current_addr] = true
+
+    -- Try to resolve one step
+    local result, rule_type = resolve_one_step(current_addr)
+
+    if not result then
+      -- No more aliases, this is canonical
+      return { current_addr }
+    end
+
+    -- Track rule application
+    if rule_type and not rules_applied[rule_type] then
+      rules_applied[rule_type] = 0
+    end
+    if rule_type then
+      rules_applied[rule_type] = rules_applied[rule_type] + 1
+    end
+
+    -- Normalize result to array
+    local targets
+    if type(result) == 'string' then
+      targets = { result }
+    elseif type(result) == 'table' then
+      targets = result
+    else
+      return { current_addr }
+    end
+
+    -- If we have multiple targets and expand_multiple is false, take first
+    if #targets > 1 and not expand_multiple then
+      if track_chain and chain then
+        table.insert(chain, targets[1])
+      end
+      return resolve_recursive(targets[1], depth + 1)
+    end
+
+    -- Expand multiple targets
+    local canonical_addrs = {}
+    for _, target in ipairs(targets) do
+      if track_chain and chain then
+        table.insert(chain, target)
+      end
+
+      -- Recursively resolve each target
+      local resolved = resolve_recursive(target, depth + 1)
+      for _, resolved_addr in ipairs(resolved) do
+        table.insert(canonical_addrs, resolved_addr)
+      end
+    end
+
+    return canonical_addrs
+  end
+
+  -- Start resolution
+  local canonical_addrs = resolve_recursive(email_str, 1)
+
+  -- Build metadata
+  local metadata = {
+    depth = table_length(visited),
+    rules_applied = rules_applied,
+    expanded = #canonical_addrs > 1,
+  }
+
+  -- Return result
+  if #canonical_addrs == 1 then
+    return canonical_addrs[1], chain, metadata
+  else
+    return canonical_addrs, chain, metadata
+  end
+end
+
+--- Resolve a single email address (backward compatible, non-recursive)
+-- @param addr email address (string or table)
+-- @param opts options table (can specify max_depth)
+-- @return canonical address or original address if no alias found
+local function resolve_address(addr, opts)
+  opts = opts or {}
+
+  -- Use recursive resolver with specified depth (default 10 for compatibility)
+  local simple_opts = {
+    max_depth = opts.max_depth or 10,
+    track_chain = opts.track_chain or false,
+    expand_multiple = opts.expand_multiple or false,
+  }
+
+  local canonical = resolve_address_recursive(addr, simple_opts)
+  return canonical
+end
+exports.resolve_address = resolve_address
+exports.resolve_address_recursive = resolve_address_recursive
+
+--- Classify message direction (inbound/outbound/internal/forwarded)
+-- @param task rspamd task
+-- @param opts classification options
+-- @return classification table with direction, from_local, to_local, canonical addresses, etc.
+local function classify_message(task, opts)
+  opts = opts or {}
+  local resolve_opts = {
+    max_depth = opts.max_depth or 10,
+    track_chain = opts.track_chain or false,
+    expand_multiple = opts.expand_multiple or true,
+  }
+
+  local classification = {
+    direction = nil,
+    from_local = false,
+    to_local = false,
+    canonical_from = nil,
+    canonical_recipients = {},
+    forwarding_detected = nil,
+    aliases_resolved = {
+      from = nil,
+      recipients = {},
+    }
+  }
+
+  -- Get authenticated user and IP
+  local user = task:get_user()
+  local ip = task:get_ip()
+  local is_authenticated = user ~= nil
+  local is_local_ip = ip and ip:is_local()
+
+  -- Resolve From address
+  local from_smtp = task:get_from('smtp')
+  if from_smtp and from_smtp[1] then
+    local from_addr = from_smtp[1]
+
+    -- Check if from is local domain
+    classification.from_local = is_local_address(from_addr)
+
+    -- Apply service rules to extract tags (but don't modify addr in task)
+    local from_copy = {
+      addr = from_addr.addr,
+      user = from_addr.user,
+      domain = from_addr.domain,
+      name = from_addr.name,
+    }
+    local _, tags = apply_service_rules(from_copy)
+    if tags and #tags > 0 then
+      classification.from_tagged = tags
+    end
+
+    -- Resolve from address
+    local canonical_from, from_chain, from_meta = resolve_address_recursive(
+      from_addr, resolve_opts)
+
+    classification.canonical_from = canonical_from
+    classification.aliases_resolved.from = {
+      chain = from_chain,
+      metadata = from_meta,
+    }
+  end
+
+  -- Resolve recipients
+  local rcpts_smtp = task:get_recipients('smtp')
+  if rcpts_smtp then
+    local any_local = false
+    local all_local = true
+
+    for _, rcpt in ipairs(rcpts_smtp) do
+      -- Check if recipient is local
+      local rcpt_is_local = is_local_address(rcpt)
+      if rcpt_is_local then
+        any_local = true
+      else
+        all_local = false
+      end
+
+      -- Resolve recipient
+      local canonical_rcpt, rcpt_chain, rcpt_meta = resolve_address_recursive(
+        rcpt, resolve_opts)
+
+      -- Handle multiple expansions
+      if type(canonical_rcpt) == 'table' then
+        for _, addr in ipairs(canonical_rcpt) do
+          table.insert(classification.canonical_recipients, addr)
+        end
+      else
+        table.insert(classification.canonical_recipients, canonical_rcpt)
+      end
+
+      table.insert(classification.aliases_resolved.recipients, {
+        original = rcpt.addr,
+        canonical = canonical_rcpt,
+        chain = rcpt_chain,
+        metadata = rcpt_meta,
+        is_local = rcpt_is_local,
+      })
+    end
+
+    classification.to_local = any_local or all_local
+  end
+
+  -- Determine direction
+  if classification.from_local and classification.to_local then
+    classification.direction = 'internal'
+  elseif classification.from_local and not classification.to_local then
+    classification.direction = 'outbound'
+  elseif not classification.from_local and classification.to_local then
+    classification.direction = 'inbound'
+  else
+    -- Neither from nor to is local - might be forwarded or external
+    classification.direction = 'external'
+  end
+
+  -- Check for forwarding (from opts if provided by plugin)
+  if opts.forwarding_detected then
+    classification.forwarding_detected = {
+      type = opts.forwarding_type,
+      info = opts.forwarding_info,
+    }
+    classification.direction = 'forwarded'
+  end
+
+  -- Override with authenticated/local IP logic
+  if is_authenticated or is_local_ip then
+    -- Authenticated users or local IPs sending mail = outbound
+    if not classification.to_local then
+      classification.direction = 'outbound'
+    elseif classification.to_local and classification.from_local then
+      classification.direction = 'internal'
+    end
+  end
+
+  return classification
+end
+exports.classify_message = classify_message
+
+--- Get module state (for debugging)
+-- @return module state table
+local function get_state()
+  return module_state
+end
+exports.get_state = get_state
+
+--- Reset module state (for testing)
+local function reset()
+  module_state = {
+    initialized = false,
+    config = nil,
+    local_domains = {},
+    unix_aliases = {},
+    virtual_aliases = {},
+    rspamd_aliases = {},
+    cache = {},
+    backends = {},
+  }
+end
+exports.reset = reset
+
+return exports
index f8218e1cb990b5bae431e5b0fd7eda618a700ebb..9bcca5ced58cb0f65f9ac14a05a4b865f4c65695 100644 (file)
@@ -12,197 +12,24 @@ distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
-]]--
-
--- Rules to detect forwarding
-
-local rspamd_util = require "rspamd_util"
-
-rspamd_config.FWD_GOOGLE = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local envfrom = task:get_from { 'smtp', 'orig' }
-    local envrcpts = task:get_recipients(1)
-    -- Forwarding will only be to a single recipient
-    if #envrcpts > 1 then
-      return false
-    end
-    -- Get recipient and compute VERP address
-    local rcpt = envrcpts[1].addr:lower()
-    local verp = rcpt:gsub('@', '=')
-    -- Get the user portion of the envfrom
-    local ef_user = envfrom[1].user:lower()
-    -- Check for a match
-    if ef_user:find('+caf_=' .. verp, 1, true) then
-      local _, _, user = ef_user:find('^(.+)+caf_=')
-      if user then
-        user = user .. '@' .. envfrom[1].domain
-        return true, user
-      end
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded by Google",
-  group = "forwarding"
-}
-
-rspamd_config.FWD_YANDEX = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local hostname = task:get_hostname()
-    if hostname and hostname:lower():find('%.yandex%.[a-z]+$') then
-      return task:has_header('X-Yandex-Forward')
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded by Yandex",
-  group = "forwarding"
-}
-
-rspamd_config.FWD_MAILRU = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local hostname = task:get_hostname()
-    if hostname and hostname:lower():find('%.mail%.ru$') then
-      return task:has_header('X-MailRu-Forward')
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded by Mail.ru",
-  group = "forwarding"
-}
-
-rspamd_config.FWD_SRS = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local envfrom = task:get_from(1)
-    local envrcpts = task:get_recipients(1)
-    -- Forwarding is only to a single recipient
-    if #envrcpts > 1 then
-      return false
-    end
-    -- Get recipient and compute rewritten SRS address
-    local srs = '=' .. envrcpts[1].domain:lower() ..
-        '=' .. envrcpts[1].user:lower()
-    if envfrom[1].user:lower():find('^srs[01]=') and
-        envfrom[1].user:lower():find(srs, 1, false)
-    then
-      return true
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded using Sender Rewriting Scheme (SRS)",
-  group = "forwarding"
-}
-
-rspamd_config.FWD_SIEVE = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local envfrom = task:get_from(1)
-    local envrcpts = task:get_recipients(1)
-    -- Forwarding is only to a single recipient
-    if #envrcpts > 1 then
-      return false
-    end
-    if envfrom[1].user:lower():find('^srs[01]=') then
-      return task:has_header('X-Sieve-Redirected-From')
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded using Sieve",
-  group = "forwarding"
-}
-
-rspamd_config.FWD_CPANEL = {
-  callback = function(task)
-    if not (task:has_from(1) and task:has_recipients(1)) then
-      return false
-    end
-    local envfrom = task:get_from(1)
-    local envrcpts = task:get_recipients(1)
-    -- Forwarding is only to a single recipient
-    if #envrcpts > 1 then
-      return false
-    end
-    if envfrom[1].user:lower():find('^srs[01]=') then
-      local rewrite_hdr = task:get_header('From-Rewrite')
-      if rewrite_hdr and rewrite_hdr:find('forwarded message') then
-        return true
-      end
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded using cPanel",
-  group = "forwarding"
-}
-
-rspamd_config.FORWARDED = {
-  callback = function(task)
-    local function normalize_addr(addr)
-      addr = string.match(addr, '^<?([^>]*)>?$') or addr
-      local cap, _, domain = string.match(addr, '^([^%+][^%+]*)(%+[^@]*)@(.*)$')
-      if cap then
-        addr = string.format('%s@%s', cap, domain)
-      end
-
-      return addr
-    end
-
-    if not task:has_recipients(1) or not task:has_recipients(2) then
-      return false
-    end
-    local envrcpts = task:get_recipients(1)
-    -- Forwarding will only be for single recipient messages
-    if #envrcpts > 1 then
-      return false
-    end
-    -- Get any other headers we might need
-    local has_list_unsub = task:has_header('List-Unsubscribe')
-    local to = task:get_recipients(2)
-    local matches = 0
-    -- Retrieve and loop through all Received headers
-    local rcvds = task:get_received_headers()
-
-    if rcvds then
-      for _, rcvd in ipairs(rcvds) do
-        local addr = rcvd['for']
-        if addr then
-          addr = normalize_addr(addr)
-          matches = matches + 1
-          -- Check that it doesn't match the envrcpt
-          if not rspamd_util.strequal_caseless(addr, envrcpts[1].addr) then
-            -- Check for mailing-lists as they will have the same signature
-            if matches < 2 and has_list_unsub and to and rspamd_util.strequal_caseless(to[1].addr, addr) then
-              return false
-            else
-              return true, 1.0, addr
-            end
-          end
-          -- Prevent any other iterations as we only want
-          -- process the first matching Received header
-          return false
-        end
-      end
-    end
-    return false
-  end,
-  score = 0.0,
-  description = "Message was forwarded",
-  group = "forwarding"
-}
+]] --
+
+-- Forwarding detection rules moved to: src/plugins/lua/aliases.lua
+--
+-- All forwarding detection functionality has been integrated into the aliases
+-- plugin to ensure correct execution order (prefilter stage before classification).
+--
+-- Symbols now registered by aliases plugin:
+--   FWD_GOOGLE, FWD_YANDEX, FWD_MAILRU
+--   FWD_SRS, FWD_SIEVE, FWD_CPANEL
+--   FORWARDED
+--
+-- To use forwarding detection, enable the aliases plugin:
+--
+--   # local.d/aliases.conf
+--   aliases {
+--     enabled = true;
+--     local_domains = ["your-domain.com"];
+--   }
+--
+-- See: ALIASES_FORWARDING_MIGRATION.md for migration details
index 4ddb00dfb97f6cf489e5b028db705b1d66164ae2..6a740f4172cf1cd2ebb209bbadebe54e1dc78911 100644 (file)
@@ -12,7 +12,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
-]]--
+]] --
 
 -- Misc rules
 
@@ -47,7 +47,7 @@ rspamd_config.R_PARTS_DIFFER = {
             score = (nd - 0.5)
           end
           task:insert_result('R_PARTS_DIFFER', score,
-              string.format('%.1f%%', tostring(100.0 * nd)))
+            string.format('%.1f%%', tostring(100.0 * nd)))
         end
       end
     end
@@ -174,7 +174,7 @@ rspamd_config.ENVFROM_PRVS = {
         prvs=USER=TAG@example.com
         btv1==TAG==USER@example.com     Barracuda appliance
         msprvs1=TAG=USER@example.com    Sparkpost email delivery service
-        ]]--
+        ]] --
     if not (task:has_from(1) and task:has_from(2)) then
       return false
     end
@@ -414,7 +414,6 @@ rspamd_config.OMOGRAPH_URL = {
 
       fun.each(function(u)
         if u:is_phished() then
-
           local h1 = u:get_host()
           local h2 = u:get_phished()
           if h2 then
@@ -488,77 +487,11 @@ rspamd_config.URL_IN_SUBJECT = {
   description = 'Subject contains URL'
 }
 
-local aliases_id = rspamd_config:register_symbol {
-  type = 'prefilter',
-  name = 'EMAIL_PLUS_ALIASES',
-  callback = function(task)
-    local function check_from(type)
-      if task:has_from(type) then
-        local addr = task:get_from(type)[1]
-        local na, tags = lua_util.remove_email_aliases(addr)
-        if na then
-          task:set_from(type, addr, 'alias')
-          task:insert_result('TAGGED_FROM', 1.0, fun.totable(
-              fun.filter(function(t)
-                return t and #t > 0
-              end, tags)))
-        end
-      end
-    end
-
-    check_from('smtp')
-    check_from('mime')
-
-    local function check_rcpt(type)
-      if task:has_recipients(type) then
-        local modified = false
-        local all_tags = {}
-        local addrs = task:get_recipients(type)
-
-        for _, addr in ipairs(addrs) do
-          local na, tags = lua_util.remove_email_aliases(addr)
-          if na then
-            modified = true
-            fun.each(function(t)
-              table.insert(all_tags, t)
-            end,
-                fun.filter(function(t)
-                  return t and #t > 0
-                end, tags))
-          end
-        end
-
-        if modified then
-          task:set_recipients(type, addrs, 'alias')
-          task:insert_result('TAGGED_RCPT', 1.0, all_tags)
-        end
-      end
-    end
-
-    check_rcpt('smtp')
-    check_rcpt('mime')
-  end,
-  priority = lua_util.symbols_priorities.top + 1,
-  description = 'Removes plus aliases from the email',
-  group = 'headers',
-}
-
-rspamd_config:register_symbol {
-  type = 'virtual',
-  parent = aliases_id,
-  name = 'TAGGED_RCPT',
-  description = 'SMTP recipients have plus tags',
-  group = 'headers',
-  score = 0.0,
-}
-rspamd_config:register_symbol {
-  type = 'virtual',
-  parent = aliases_id,
-  name = 'TAGGED_FROM',
-  description = 'SMTP from has plus tags',
-  group = 'headers',
-  score = 0.0,
-}
+-- EMAIL_PLUS_ALIASES, TAGGED_FROM, TAGGED_RCPT symbols moved to:
+-- src/plugins/lua/aliases.lua
+--
+-- To use this functionality, enable the aliases plugin in local.d/aliases.conf:
+--   aliases { enabled = true; local_domains = ["your-domain.com"]; }
 
 local check_from_display_name = rspamd_config:register_symbol {
   type = 'callback,mime',
@@ -865,7 +798,7 @@ rspamd_config.COMPLETELY_EMPTY = {
 
 -- Preserve compatibility
 local rdns_auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, 'once_received',
-    false, false)
+  false, false)
 -- Check for the hostname if it was not set
 local rnds_check_id = rspamd_config:register_symbol {
   name = 'RDNS_CHECK',
@@ -885,15 +818,16 @@ local rnds_check_id = rspamd_config:register_symbol {
             task:insert_result('RDNS_NONE', 1.0)
           else
             rspamd_logger.infox(task, 'source hostname has not been passed to Rspamd from MTA, ' ..
-                'but we could resolve source IP address PTR %s as "%s"',
-                to_resolve, results[1])
+              'but we could resolve source IP address PTR %s as "%s"',
+              to_resolve, results[1])
             task:set_hostname(results[1])
           end
         end
-        task:get_resolver():resolve_ptr({ task = task,
-                                          name = task_ip:to_string(),
-                                          callback = rdns_dns_cb,
-                                          forced = true
+        task:get_resolver():resolve_ptr({
+          task = task,
+          name = task_ip:to_string(),
+          callback = rdns_dns_cb,
+          forced = true
         })
       end
     end
@@ -905,7 +839,7 @@ local rnds_check_id = rspamd_config:register_symbol {
   condition = function(task)
     local task_ip = task:get_ip()
     if ((not rdns_auth_and_local_conf[1] and task:get_user()) or
-        (not rdns_auth_and_local_conf[2] and task_ip and task_ip:is_local())) then
+          (not rdns_auth_and_local_conf[2] and task_ip and task_ip:is_local())) then
       return false
     end
 
@@ -929,4 +863,4 @@ rspamd_config:register_symbol {
   description = 'DNS failure resolving RDNS',
   group = 'hfilter',
   parent = rnds_check_id,
-}
\ No newline at end of file
+}
diff --git a/src/plugins/lua/aliases.lua b/src/plugins/lua/aliases.lua
new file mode 100644 (file)
index 0000000..d1a3dcd
--- /dev/null
@@ -0,0 +1,653 @@
+--[[
+Copyright (c) 2025, Vsevolod Stakhov <vsevolod@rspamd.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+]] --
+
+-- luacheck: globals rspamd_config confighelp
+
+--[[[
+-- @module aliases
+-- Email aliases resolution and message classification plugin
+--
+-- This plugin:
+-- - Resolves email aliases (Unix, virtual, service-specific)
+-- - Classifies message direction (inbound/outbound/internal)
+-- - Applies plus-addressing and Gmail-specific rules
+-- - Inserts classification symbols
+--]]
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_aliases = require "lua_aliases"
+local fun = require "fun"
+
+local N = "aliases"
+
+--[[ Forwarding detection functions (moved from rules/forwarding.lua) ]] --
+
+--- Check for Google forwarding (VERP-based)
+local function check_fwd_google(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local envfrom = task:get_from { 'smtp', 'orig' }
+  local envrcpts = task:get_recipients(1)
+  if #envrcpts > 1 then
+    return false
+  end
+  local rcpt = envrcpts[1].addr:lower()
+  local verp = rcpt:gsub('@', '=')
+  local ef_user = envfrom[1].user:lower()
+  if ef_user:find('+caf_=' .. verp, 1, true) then
+    local _, _, user = ef_user:find('^(.+)+caf_=')
+    if user then
+      user = user .. '@' .. envfrom[1].domain
+      return true, 'google', user
+    end
+  end
+  return false
+end
+
+--- Check for Yandex forwarding
+local function check_fwd_yandex(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local hostname = task:get_hostname()
+  if hostname and hostname:lower():find('%.yandex%.[a-z]+$') then
+    if task:has_header('X-Yandex-Forward') then
+      return true, 'yandex'
+    end
+  end
+  return false
+end
+
+--- Check for Mail.ru forwarding
+local function check_fwd_mailru(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local hostname = task:get_hostname()
+  if hostname and hostname:lower():find('%.mail%.ru$') then
+    if task:has_header('X-MailRu-Forward') then
+      return true, 'mailru'
+    end
+  end
+  return false
+end
+
+--- Check for SRS (Sender Rewriting Scheme) forwarding
+local function check_fwd_srs(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local envfrom = task:get_from(1)
+  local envrcpts = task:get_recipients(1)
+  if #envrcpts > 1 then
+    return false
+  end
+  local srs = '=' .. envrcpts[1].domain:lower() ..
+      '=' .. envrcpts[1].user:lower()
+  if envfrom[1].user:lower():find('^srs[01]=') and
+      envfrom[1].user:lower():find(srs, 1, false) then
+    return true, 'srs'
+  end
+  return false
+end
+
+--- Check for Sieve forwarding
+local function check_fwd_sieve(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local envfrom = task:get_from(1)
+  local envrcpts = task:get_recipients(1)
+  if #envrcpts > 1 then
+    return false
+  end
+  if envfrom[1].user:lower():find('^srs[01]=') then
+    if task:has_header('X-Sieve-Redirected-From') then
+      return true, 'sieve'
+    end
+  end
+  return false
+end
+
+--- Check for cPanel forwarding
+local function check_fwd_cpanel(task)
+  if not (task:has_from(1) and task:has_recipients(1)) then
+    return false
+  end
+  local envfrom = task:get_from(1)
+  local envrcpts = task:get_recipients(1)
+  if #envrcpts > 1 then
+    return false
+  end
+  if envfrom[1].user:lower():find('^srs[01]=') then
+    local rewrite_hdr = task:get_header('From-Rewrite')
+    if rewrite_hdr and rewrite_hdr:find('forwarded message') then
+      return true, 'cpanel'
+    end
+  end
+  return false
+end
+
+--- Check for generic forwarding (Received headers analysis)
+local function check_fwd_generic(task)
+  local function normalize_addr(addr)
+    addr = string.match(addr, '^<?([^>]*)>?$') or addr
+    local cap, _, domain = string.match(addr, '^([^%+][^%+]*)(%+[^@]*)@(.*)$')
+    if cap then
+      addr = string.format('%s@%s', cap, domain)
+    end
+    return addr
+  end
+
+  if not task:has_recipients(1) or not task:has_recipients(2) then
+    return false
+  end
+  local envrcpts = task:get_recipients(1)
+  if #envrcpts > 1 then
+    return false
+  end
+  local has_list_unsub = task:has_header('List-Unsubscribe')
+  local to = task:get_recipients(2)
+  local matches = 0
+  local rcvds = task:get_received_headers()
+
+  if rcvds then
+    for _, rcvd in ipairs(rcvds) do
+      local addr = rcvd['for']
+      if addr then
+        addr = normalize_addr(addr)
+        matches = matches + 1
+        if not rspamd_util.strequal_caseless(addr, envrcpts[1].addr) then
+          if matches < 2 and has_list_unsub and to and rspamd_util.strequal_caseless(to[1].addr, addr) then
+            return false
+          else
+            return true, 'generic', addr
+          end
+        end
+        return false
+      end
+    end
+  end
+  return false
+end
+
+--- Detect all forwarding types
+-- @param task rspamd task
+-- @return detected (boolean), forwarding_type (string), additional_info (string or nil)
+local function detect_forwarding(task)
+  -- Check specific forwarding types first (faster)
+  local detected, fwd_type, info
+
+  detected, fwd_type, info = check_fwd_google(task)
+  if detected then return detected, fwd_type, info end
+
+  detected, fwd_type, info = check_fwd_yandex(task)
+  if detected then return detected, fwd_type, info end
+
+  detected, fwd_type, info = check_fwd_mailru(task)
+  if detected then return detected, fwd_type, info end
+
+  detected, fwd_type, info = check_fwd_srs(task)
+  if detected then return detected, fwd_type, info end
+
+  detected, fwd_type, info = check_fwd_sieve(task)
+  if detected then return detected, fwd_type, info end
+
+  detected, fwd_type, info = check_fwd_cpanel(task)
+  if detected then return detected, fwd_type, info end
+
+  -- Generic check last (most expensive)
+  detected, fwd_type, info = check_fwd_generic(task)
+  if detected then return detected, fwd_type, info end
+
+  return false
+end
+
+if confighelp then
+  rspamd_config:add_example(nil, N,
+    "Email aliases resolution and message classification",
+    [[
+aliases {
+       enabled = true;
+
+#Unix aliases
+       system_aliases = "/etc/aliases";
+
+#Virtual aliases
+       virtual_aliases = "/etc/postfix/virtual";
+
+#Local domains
+       local_domains = ["example.com", "mail.example.com"];
+
+#Options
+       max_recursion_depth = 10;
+       expand_multiple = true;
+       enable_gmail_rules = true;
+       enable_plus_aliases = true;
+}
+]])
+  return
+end
+
+-- Configuration
+local settings = {
+  enabled = false,
+
+  --Backend configurations
+  system_aliases = nil,
+  virtual_aliases = nil,
+  local_domains = nil,
+  rspamd_aliases = nil,
+
+  --Resolution options
+  max_recursion_depth = 10,
+  expand_multiple = true,
+  track_chain = false,
+
+  --Application scope
+  apply_to_mime = true,
+  apply_to_smtp = true,
+
+  --Service - specific rules
+  enable_gmail_rules = true,
+  enable_plus_aliases = true,
+
+  --Symbol names
+  symbol_local_inbound = 'LOCAL_INBOUND',
+  symbol_local_outbound = 'LOCAL_OUTBOUND',
+  symbol_internal_mail = 'INTERNAL_MAIL',
+  symbol_alias_resolved = 'ALIAS_RESOLVED',
+  symbol_tagged_from = 'TAGGED_FROM',
+  symbol_tagged_rcpt = 'TAGGED_RCPT',
+
+  --Symbol scores
+  score_local_inbound = 0.0,
+  score_local_outbound = 0.0,
+  score_internal_mail = 0.0,
+  score_alias_resolved = 0.0,
+  score_tagged_from = 0.0,
+  score_tagged_rcpt = 0.0,
+}
+
+--- Helper to update address fields after resolution
+-- @param addr address table
+-- @param new_user new user part
+-- @param new_domain new domain part
+local function set_addr(addr, new_user, new_domain)
+  if new_user then
+    addr.user = new_user
+  end
+  if new_domain then
+    addr.domain = new_domain
+  end
+
+  if addr.domain then
+    addr.addr = string.format('%s@%s', addr.user, addr.domain)
+  else
+    addr.addr = string.format('%s@', addr.user)
+  end
+
+  if addr.name and #addr.name > 0 then
+    addr.raw = string.format('"%s" <%s>', addr.name, addr.addr)
+  else
+    addr.raw = string.format('<%s>', addr.addr)
+  end
+end
+
+--- Process aliases callback (prefilter)
+local function aliases_callback(task)
+  local resolve_opts = {
+    max_depth = settings.max_recursion_depth,
+    track_chain = settings.track_chain,
+    expand_multiple = false, -- Don't expand in simple resolution
+  }
+
+  local alias_resolved = false
+  local tagged_from = {}
+  local tagged_rcpt = {}
+
+  -- Detect forwarding BEFORE any modifications
+  local forwarding_detected, fwd_type, fwd_info = detect_forwarding(task)
+
+  -- Classify message BEFORE modifying addresses (important!)
+  local classification = lua_aliases.classify_message(task, {
+    max_depth = settings.max_recursion_depth,
+    track_chain = false,
+    expand_multiple = settings.expand_multiple,
+    forwarding_detected = forwarding_detected,
+    forwarding_type = fwd_type,
+    forwarding_info = fwd_info,
+  })
+
+  --- Check and resolve From address
+  -- @param addr_type 'smtp' or 'mime'
+  local function check_from(addr_type)
+    if not task:has_from(addr_type) then
+      return
+    end
+
+    local addr = task:get_from(addr_type)[1]
+    local original_addr = addr.addr
+
+    -- Apply service-specific rules (Gmail, plus-aliases)
+    if settings.enable_gmail_rules or settings.enable_plus_aliases then
+      local nu, tags, nd = lua_aliases.apply_service_rules(addr)
+
+      if nu or nd then
+        set_addr(addr, nu, nd)
+
+        if tags and #tags > 0 then
+          fun.each(function(t)
+            if t and #t > 0 then
+              table.insert(tagged_from, t)
+            end
+          end, tags)
+        end
+
+        alias_resolved = true
+      end
+    end
+
+    -- Resolve through alias system
+    local canonical = lua_aliases.resolve_address(addr, resolve_opts)
+    if canonical and canonical ~= original_addr then
+      -- Update address
+      local user, domain = canonical:match('^([^@]+)@(.+)$')
+      if user and domain then
+        set_addr(addr, user, domain)
+        alias_resolved = true
+      end
+    end
+
+    -- Update in task
+    if alias_resolved then
+      task:set_from(addr_type, addr, 'alias')
+    end
+  end
+
+  --- Check and resolve recipients
+  -- @param addr_type 'smtp' or 'mime'
+  local function check_rcpt(addr_type)
+    if not task:has_recipients(addr_type) then
+      return
+    end
+
+    local modified = false
+    local addrs = task:get_recipients(addr_type)
+
+    for _, addr in ipairs(addrs) do
+      local original_addr = addr.addr
+
+      -- Apply service-specific rules
+      if settings.enable_gmail_rules or settings.enable_plus_aliases then
+        local nu, tags, nd = lua_aliases.apply_service_rules(addr)
+        if nu or nd then
+          set_addr(addr, nu, nd)
+
+          if tags and #tags > 0 then
+            fun.each(function(t)
+              if t and #t > 0 then
+                table.insert(tagged_rcpt, t)
+              end
+            end, tags)
+          end
+
+          modified = true
+          alias_resolved = true
+        end
+      end
+
+      -- Resolve through alias system
+      local canonical = lua_aliases.resolve_address(addr, resolve_opts)
+      if canonical and canonical ~= original_addr then
+        -- Update address
+        local user, domain = canonical:match('^([^@]+)@(.+)$')
+        if user and domain then
+          set_addr(addr, user, domain)
+          modified = true
+          alias_resolved = true
+        end
+      end
+    end
+
+    -- Update in task
+    if modified then
+      task:set_recipients(addr_type, addrs, 'alias')
+    end
+  end
+
+  -- Process SMTP addresses
+  if settings.apply_to_smtp then
+    check_from('smtp')
+    check_rcpt('smtp')
+  end
+
+  -- Process MIME addresses
+  if settings.apply_to_mime then
+    check_from('mime')
+    check_rcpt('mime')
+  end
+
+  -- Insert tagging symbols
+  if #tagged_from > 0 then
+    task:insert_result(settings.symbol_tagged_from, 1.0, tagged_from)
+  end
+
+  if #tagged_rcpt > 0 then
+    task:insert_result(settings.symbol_tagged_rcpt, 1.0, tagged_rcpt)
+  end
+
+  -- Insert forwarding symbols (for backward compatibility with rules/forwarding.lua)
+  if forwarding_detected then
+    if fwd_type == 'google' then
+      task:insert_result('FWD_GOOGLE', 1.0, fwd_info or '')
+    elseif fwd_type == 'yandex' then
+      task:insert_result('FWD_YANDEX', 1.0)
+    elseif fwd_type == 'mailru' then
+      task:insert_result('FWD_MAILRU', 1.0)
+    elseif fwd_type == 'srs' then
+      task:insert_result('FWD_SRS', 1.0)
+    elseif fwd_type == 'sieve' then
+      task:insert_result('FWD_SIEVE', 1.0)
+    elseif fwd_type == 'cpanel' then
+      task:insert_result('FWD_CPANEL', 1.0)
+    elseif fwd_type == 'generic' then
+      task:insert_result('FORWARDED', 1.0, fwd_info or '')
+    end
+
+    lua_util.debugm(N, task, 'detected forwarding: %s', fwd_type)
+  end
+
+  -- Insert classification symbols (classification was done at the beginning)
+  if classification.direction == 'inbound' then
+    task:insert_result(settings.symbol_local_inbound, 1.0)
+  elseif classification.direction == 'outbound' then
+    task:insert_result(settings.symbol_local_outbound, 1.0)
+  elseif classification.direction == 'internal' then
+    task:insert_result(settings.symbol_internal_mail, 1.0)
+  end
+
+  -- Insert alias resolution symbol
+  if alias_resolved then
+    task:insert_result(settings.symbol_alias_resolved, 1.0)
+  end
+
+  -- Store classification in task cache for other plugins
+  task:cache_set('aliases_classification', classification)
+
+  lua_util.debugm(N, task, 'classification: %s, from_local: %s, to_local: %s',
+    classification.direction,
+    classification.from_local,
+    classification.to_local)
+end
+
+-- Module initialization
+local opts = rspamd_config:get_all_opt(N)
+if opts then
+  settings = lua_util.override_defaults(settings, opts)
+
+  if settings.enabled then
+    -- Initialize lua_aliases library
+    local init_opts = {
+      system_aliases = settings.system_aliases,
+      virtual_aliases = settings.virtual_aliases,
+      local_domains = settings.local_domains,
+      rspamd_aliases = settings.rspamd_aliases,
+    }
+
+    local success = lua_aliases.init(rspamd_config, init_opts)
+    if not success then
+      rspamd_logger.errx(rspamd_config, 'failed to initialize lua_aliases')
+      return
+    end
+
+    -- Register prefilter callback
+    local id = rspamd_config:register_symbol({
+      name = 'ALIASES_CHECK',
+      type = 'prefilter',
+      callback = aliases_callback,
+      priority = lua_util.symbols_priorities.top + 1,
+      flags = 'nice,explicit_disable',
+      group = 'aliases',
+    })
+
+    -- Register classification symbols
+    rspamd_config:register_symbol({
+      name = settings.symbol_local_inbound,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_local_inbound,
+      description = 'Mail from external to local domain',
+      group = 'aliases',
+    })
+
+    rspamd_config:register_symbol({
+      name = settings.symbol_local_outbound,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_local_outbound,
+      description = 'Mail from local to external domain',
+      group = 'aliases',
+    })
+
+    rspamd_config:register_symbol({
+      name = settings.symbol_internal_mail,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_internal_mail,
+      description = 'Mail from local to local domain',
+      group = 'aliases',
+    })
+
+    rspamd_config:register_symbol({
+      name = settings.symbol_alias_resolved,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_alias_resolved,
+      description = 'Address was resolved through aliases',
+      group = 'aliases',
+    })
+
+    rspamd_config:register_symbol({
+      name = settings.symbol_tagged_from,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_tagged_from,
+      description = 'From address has plus-tags',
+      group = 'aliases',
+    })
+
+    rspamd_config:register_symbol({
+      name = settings.symbol_tagged_rcpt,
+      type = 'virtual',
+      parent = id,
+      score = settings.score_tagged_rcpt,
+      description = 'Recipient has plus-tags',
+      group = 'aliases',
+    })
+
+    -- Register forwarding detection symbols (moved from rules/forwarding.lua)
+    rspamd_config:register_symbol({
+      name = 'FWD_GOOGLE',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded by Google',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FWD_YANDEX',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded by Yandex',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FWD_MAILRU',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded by Mail.ru',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FWD_SRS',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded using Sender Rewriting Scheme (SRS)',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FWD_SIEVE',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded using Sieve',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FWD_CPANEL',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded using cPanel',
+      group = 'forwarding',
+    })
+
+    rspamd_config:register_symbol({
+      name = 'FORWARDED',
+      type = 'virtual',
+      parent = id,
+      score = 0.0,
+      description = 'Message was forwarded',
+      group = 'forwarding',
+    })
+
+    rspamd_logger.infox(rspamd_config, 'aliases plugin enabled with forwarding detection')
+  else
+    rspamd_logger.infox(rspamd_config, 'aliases plugin disabled')
+  end
+end
diff --git a/test/functional/cases/001_merged/360_aliases.robot b/test/functional/cases/001_merged/360_aliases.robot
new file mode 100644 (file)
index 0000000..1ad08af
--- /dev/null
@@ -0,0 +1,156 @@
+*** Settings ***
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Test Cases ***
+
+# Basic alias resolution tests
+
+UNIX ALIAS - SIMPLE RESOLUTION
+  [Documentation]  Test simple Unix alias resolution (postmaster -> root)
+  Scan File  ${RSPAMD_TESTDIR}/messages/aliases_simple.eml
+  ...  From=sender@external.com
+  ...  Rcpt=postmaster@example.com
+  Expect Symbol  ALIAS_RESOLVED
+  Expect Symbol  LOCAL_INBOUND
+
+PLUS ADDRESSING - BASIC
+  [Documentation]  Test basic plus addressing (user+tag@domain)
+  Scan File  ${RSPAMD_TESTDIR}/messages/aliases_plus.eml
+  ...  From=sender@external.com
+  ...  Rcpt=user+tag@example.com
+  Expect Symbol  TAGGED_RCPT
+  Expect Symbol  LOCAL_INBOUND
+
+# Message classification tests
+
+CLASSIFICATION - INTERNAL MAIL
+  [Documentation]  Test internal mail classification (local -> local)
+  Scan File  ${RSPAMD_TESTDIR}/messages/aliases_internal.eml
+  ...  From=user1@example.com
+  ...  Rcpt=user2@example.com
+  ...  IP=127.0.0.1
+  Expect Symbol  INTERNAL_MAIL
+  Do Not Expect Symbol  LOCAL_INBOUND
+  Do Not Expect Symbol  LOCAL_OUTBOUND
+
+CLASSIFICATION - OUTBOUND MAIL
+  [Documentation]  Test outbound mail classification (local -> external)
+  Scan File  ${RSPAMD_TESTDIR}/messages/aliases_outbound.eml
+  ...  From=user@example.com
+  ...  Rcpt=external@foreign.com
+  ...  IP=127.0.0.1
+  Expect Symbol  LOCAL_OUTBOUND
+  Do Not Expect Symbol  LOCAL_INBOUND
+  Do Not Expect Symbol  INTERNAL_MAIL
+
+CLASSIFICATION - INBOUND MAIL
+  [Documentation]  Test inbound mail classification (external -> local)
+  Scan File  ${RSPAMD_TESTDIR}/messages/aliases_inbound.eml
+  ...  From=external@foreign.com
+  ...  Rcpt=support@example.com
+  Expect Symbol  LOCAL_INBOUND
+  Expect Symbol  ALIAS_RESOLVED
+  Do Not Expect Symbol  LOCAL_OUTBOUND
+  Do Not Expect Symbol  INTERNAL_MAIL
+
+# Gmail-specific tests
+
+GMAIL DOTS REMOVAL
+  [Documentation]  Test Gmail dots removal (first.last@gmail.com -> firstlast@gmail.com)
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=first.last@gmail.com
+  ...  Rcpt=user@example.com
+  Expect Symbol  ALIAS_RESOLVED
+  # Note: TAGGED_FROM is only for plus-addressing, not for dots removal
+
+GMAIL PLUS ADDRESSING
+  [Documentation]  Test Gmail plus addressing (user+tag@gmail.com -> user@gmail.com)
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=external@test.com
+  ...  Rcpt=user+newsletters@gmail.com
+  Expect Symbol  TAGGED_RCPT
+  Expect Symbol  ALIAS_RESOLVED
+
+GMAIL DOTS AND PLUS
+  [Documentation]  Test Gmail dots + plus addressing combined
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=first.last+tag@gmail.com
+  ...  Rcpt=user@example.com
+  Expect Symbol  TAGGED_FROM
+  Expect Symbol  ALIAS_RESOLVED
+
+# Virtual aliases tests
+
+VIRTUAL ALIAS - SIMPLE
+  [Documentation]  Test virtual alias resolution (contact@example.com -> support@example.com)
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=sender@external.com
+  ...  Rcpt=contact@example.com
+  Expect Symbol  ALIAS_RESOLVED
+  Expect Symbol  LOCAL_INBOUND
+
+# Chained aliases tests
+
+CHAINED ALIAS RESOLUTION
+  [Documentation]  Test chained alias resolution (sales -> team-sales -> sales-inbox@example.com)
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=customer@external.com
+  ...  Rcpt=sales@example.com
+  Expect Symbol  ALIAS_RESOLVED
+  Expect Symbol  LOCAL_INBOUND
+
+# Rspamd inline aliases tests
+
+RSPAMD INLINE ALIAS
+  [Documentation]  Test rspamd inline alias from config (rspamd-alias@example.com)
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=sender@external.com
+  ...  Rcpt=rspamd-alias@example.com
+  Expect Symbol  ALIAS_RESOLVED
+  Expect Symbol  LOCAL_INBOUND
+
+# Local domain detection tests
+
+LOCAL DOMAIN - POSITIVE
+  [Documentation]  Test local domain detection for example.com
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=user@example.com
+  ...  Rcpt=other@example.com
+  ...  IP=127.0.0.1
+  Expect Symbol  INTERNAL_MAIL
+
+LOCAL DOMAIN - SUBDOMAIN
+  [Documentation]  Test local domain detection for mail.example.com
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=user@mail.example.com
+  ...  Rcpt=other@example.com
+  ...  IP=127.0.0.1
+  Expect Symbol  INTERNAL_MAIL
+
+LOCAL DOMAIN - NEGATIVE
+  [Documentation]  Test that external domain is not detected as local
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=user@foreign.com
+  ...  Rcpt=other@example.com
+  Expect Symbol  LOCAL_INBOUND
+  Do Not Expect Symbol  INTERNAL_MAIL
+
+# Combined tests
+
+PLUS ADDRESSING WITH ALIAS
+  [Documentation]  Test plus addressing combined with alias resolution
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=sender@external.com
+  ...  Rcpt=support+urgent@example.com
+  Expect Symbol  TAGGED_RCPT
+  Expect Symbol  ALIAS_RESOLVED
+
+FROM AND RCPT TAGGED
+  [Documentation]  Test when both from and recipient have plus tags
+  Scan File  ${RSPAMD_TESTDIR}/messages/spam_message.eml
+  ...  From=sender+tag@external.com
+  ...  Rcpt=user+tag@example.com
+  Expect Symbol  TAGGED_FROM
+  Expect Symbol  TAGGED_RCPT
diff --git a/test/functional/configs/maps/test_local_domains.map b/test/functional/configs/maps/test_local_domains.map
new file mode 100644 (file)
index 0000000..315e60b
--- /dev/null
@@ -0,0 +1,9 @@
+# Local domains list
+# One domain per line
+
+example.com
+mail.example.com
+internal.example.com
+
+# Comments are allowed
+# test.example.com (disabled)
diff --git a/test/functional/configs/maps/test_unix_aliases.map b/test/functional/configs/maps/test_unix_aliases.map
new file mode 100644 (file)
index 0000000..fa9ceaa
--- /dev/null
@@ -0,0 +1,28 @@
+# Unix-style aliases for testing
+# Format: alias: target1, target2, ...
+
+# Simple alias
+postmaster: root
+
+# Alias to email
+abuse: admin@example.com
+
+# Multiple targets
+support: user1@example.com, user2@example.com
+
+# Alias to local user
+webmaster: admin
+
+# Chained alias (sales -> team-sales)
+sales: team-sales
+
+# Terminal alias
+team-sales: sales-inbox@example.com
+
+# Alias with comments
+info: info-dept@example.com  # Information department
+
+# Line continuation test
+all-staff: user1@example.com, \
+           user2@example.com, \
+           user3@example.com
diff --git a/test/functional/configs/maps/test_virtual_aliases.map b/test/functional/configs/maps/test_virtual_aliases.map
new file mode 100644 (file)
index 0000000..227d9a6
--- /dev/null
@@ -0,0 +1,14 @@
+# Virtual aliases (Postfix virtual format)
+# Format: source target
+
+# Simple virtual alias
+contact@example.com support@example.com
+
+# Domain alias
+info@test.example.com info@example.com
+
+# Virtual to real mapping
+virtual@example.com real-user@internal.example.com
+
+# Catchall example
+@catchall.example.com catchall-box@example.com
index 1d3b17cef28738190db43b4b2ef78ff337e03864..bac414aa17c289535365d0ce2662c0dbe4afaf7a 100644 (file)
@@ -14,6 +14,31 @@ metric_exporter {
   enabled = false;
 }
 
+aliases {
+  enabled = true;
+
+  # Unix aliases
+  system_aliases = "{= env.TESTDIR =}/configs/maps/test_unix_aliases.map";
+
+  # Virtual aliases
+  virtual_aliases = "{= env.TESTDIR =}/configs/maps/test_virtual_aliases.map";
+
+  # Local domains - inline для уверенности что загрузятся
+  local_domains = ["example.com", "mail.example.com", "internal.example.com"];
+
+  # Rspamd-specific inline aliases
+  rspamd_aliases = {
+    "rspamd-alias@example.com" = "real-rspamd@example.com";
+    "multi-alias@example.com" = ["target1@example.com", "target2@example.com"];
+  }
+
+  # Options
+  max_recursion_depth = 10;
+  expand_multiple = true;
+  enable_gmail_rules = true;
+  enable_plus_aliases = true;
+}
+
 emails {
   "whitelist" = [
     "rspamd-test.com"
diff --git a/test/functional/messages/aliases_inbound.eml b/test/functional/messages/aliases_inbound.eml
new file mode 100644 (file)
index 0000000..be88816
--- /dev/null
@@ -0,0 +1,8 @@
+From: external@foreign.com
+To: support@example.com
+Subject: Test inbound mail
+Message-ID: <test-inbound@example.com>
+Date: Mon, 01 Jan 2025 10:00:00 +0000
+
+This is a test message for inbound mail classification.
+Sender is external, recipient is local and should be resolved through alias.
diff --git a/test/functional/messages/aliases_internal.eml b/test/functional/messages/aliases_internal.eml
new file mode 100644 (file)
index 0000000..6380a15
--- /dev/null
@@ -0,0 +1,8 @@
+From: user1@example.com
+To: user2@example.com
+Subject: Test internal mail
+Message-ID: <test-internal@example.com>
+Date: Mon, 01 Jan 2025 10:00:00 +0000
+
+This is a test message for internal mail classification.
+Both sender and recipient are in local domains.
diff --git a/test/functional/messages/aliases_outbound.eml b/test/functional/messages/aliases_outbound.eml
new file mode 100644 (file)
index 0000000..47bddf9
--- /dev/null
@@ -0,0 +1,8 @@
+From: user@example.com
+To: external@foreign.com
+Subject: Test outbound mail
+Message-ID: <test-outbound@example.com>
+Date: Mon, 01 Jan 2025 10:00:00 +0000
+
+This is a test message for outbound mail classification.
+Sender is local, recipient is external.
diff --git a/test/functional/messages/aliases_plus.eml b/test/functional/messages/aliases_plus.eml
new file mode 100644 (file)
index 0000000..750c4b2
--- /dev/null
@@ -0,0 +1,7 @@
+From: sender@external.com
+To: user+tag@example.com
+Subject: Test plus addressing
+Message-ID: <test-plus@example.com>
+Date: Mon, 01 Jan 2025 10:00:00 +0000
+
+This is a test message for plus addressing.
diff --git a/test/functional/messages/aliases_simple.eml b/test/functional/messages/aliases_simple.eml
new file mode 100644 (file)
index 0000000..a9fdd77
--- /dev/null
@@ -0,0 +1,8 @@
+From: sender@external.com
+To: postmaster@example.com
+Subject: Test simple alias
+Message-ID: <test-simple-alias@example.com>
+Date: Mon, 01 Jan 2025 10:00:00 +0000
+
+This is a test message for simple alias resolution.
+Postmaster should resolve to root.