--- /dev/null
+# 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"
+}
--- /dev/null
+--[[
+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
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
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
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
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
fun.each(function(u)
if u:is_phished() then
-
local h1 = u:get_host()
local h2 = u:get_phished()
if h2 then
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',
-- 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',
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
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
description = 'DNS failure resolving RDNS',
group = 'hfilter',
parent = rnds_check_id,
-}
\ No newline at end of file
+}
--- /dev/null
+--[[
+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
--- /dev/null
+*** 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
--- /dev/null
+# Local domains list
+# One domain per line
+
+example.com
+mail.example.com
+internal.example.com
+
+# Comments are allowed
+# test.example.com (disabled)
--- /dev/null
+# 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
--- /dev/null
+# 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
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"
--- /dev/null
+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.
--- /dev/null
+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.
--- /dev/null
+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.
--- /dev/null
+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.
--- /dev/null
+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.