← Back
Editing: aliases.lua
--[[ 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 = true, --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 -- Only update if we have both user and domain as non-empty strings if addr.user and addr.user ~= '' and addr.domain and addr.domain ~= '' then addr.addr = string.format('%s@%s', addr.user, addr.domain) 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 else -- Invalid address - don't modify lua_util.debugm(N, rspamd_config, 'set_addr: invalid address user=%s domain=%s, not modifying', addr.user or 'nil', addr.domain or 'nil') 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 -- The From address is the sender's identity: SPF, DKIM and DMARC all key on -- its domain, so only the local part may be rewritten here; any rewrite that -- would change the domain is discarded -- @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_domain = addr.domain local modified = false local function same_domain(domain) return domain and original_domain and domain:lower() == original_domain:lower() end -- 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 nd and not same_domain(nd) then lua_util.debugm(N, task, 'refuse to rewrite %s from domain: %s -> %s', addr_type, original_domain, nd) nd = nil end 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 modified = true end end -- Resolve through alias system local canonical = lua_aliases.resolve_address(addr, resolve_opts) if canonical and canonical:lower() ~= addr.addr:lower() then -- Update address local user, domain = canonical:match('^([^@]+)@(.+)$') if user and domain then if same_domain(domain) then set_addr(addr, user, domain) modified = true else lua_util.debugm(N, task, 'refuse to resolve %s from %s -> %s: domain change', addr_type, addr.addr, canonical) end end end -- Update in task if modified then task:set_from(addr_type, addr, 'alias') alias_resolved = true 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:lower() ~= original_addr:lower() 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
Save File
Cancel