HEX
Server: Apache/2
System: Linux nexus-01 4.18.0-553.120.1.el8_10.x86_64 #1 SMP Mon Apr 20 18:04:27 EDT 2026 x86_64
User: aglcoke (1118)
PHP: 8.2.31
Disabled: mail,exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
Upload Files
File: //proc/1/task/1/root/proc/thread-self/root/usr/share/rspamd/plugins/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
  -- @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:lower() ~= original_addr:lower() 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: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