--[[
Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
Copyright (c) 2016, Alexey Savelyev <info@homeweb.ru>

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.
]]--

--[[
Example domains whitelist config:
greylist {
  # Search "example.com" and "mail.example.com" for "mx.out.mail.example.com":
  whitelist_domains_url = [
    "$LOCAL_CONFDIR/local.d/maps.d/greylist-whitelist-domains.inc",
    "${CONFDIR}/maps.d/maillist.inc",
    "${CONFDIR}/maps.d/redirectors.inc",
    "${CONFDIR}/maps.d/dmarc_whitelist.inc",
    "${CONFDIR}/maps.d/spf_dkim_whitelist.inc",
    "${CONFDIR}/maps.d/surbl-whitelist.inc",
    "https://maps.rspamd.com/freemail/free.txt.zst"
  ];
}
Example config for exim users:
greylist {
  action = "greylist";
}
--]]

if confighelp then
  rspamd_config:add_example(nil, 'greylist',
      "Performs adaptive greylisting using Redis",
      [[
greylist {
  # Buckets expire (1 day by default)
  expire = 1d;
  # Greylisting timeout
  timeout = 5m;
  # Redis prefix
  key_prefix = 'rg';
  # Use body hash up to this value of bytes for greylisting
  max_data_len = 10k;
  # Default greylisting message
  message = 'Try again later';
  # Append symbol on greylisting
  symbol = 'GREYLIST';
  # Default action change (for Exim use `greylist`)
  action = 'soft reject';
  # Skip greylisting if one of the following symbols has been found
  whitelist_symbols = [];
  # Mask bits for ipv4
  ipv4_mask = 19;
  # Mask bits for ipv6
  ipv6_mask = 64;
   # Tell when greylisting is expired (appended to `message`)
  report_time = false;
  # Greylist local messages
  check_local = false;
  # Greylist messages from authenticated users
  check_authed = false;
}
  ]])
  return
end

-- A plugin that implements greylisting using redis

local redis_params
local whitelisted_ip
local whitelist_domains_map
local toint = math.ifloor or math.floor
local settings = {
  expire = 86400, -- 1 day by default
  timeout = 300, -- 5 minutes by default
  key_prefix = 'rg', -- default hash name
  max_data_len = 10240, -- default data limit to hash
  message = 'Try again later', -- default greylisted message
  symbol = 'GREYLIST',
  action = 'soft reject', -- default greylisted action
  whitelist_symbols = {}, -- whitelist when specific symbols have been found
  ipv4_mask = 19, -- Mask bits for ipv4
  ipv6_mask = 64, -- Mask bits for ipv6
  report_time = false, -- Tell when greylisting is expired (appended to `message`)
  check_local = false,
  check_authed = false,
}

local rspamd_logger = require "rspamd_logger"
local rspamd_util = require "rspamd_util"
local lua_redis = require "lua_redis"
local lua_util = require "lua_util"
local fun = require "fun"
local hash = require "rspamd_cryptobox_hash"
local rspamd_lua_utils = require "lua_util"
local lua_map = require "lua_maps"
local N = "greylist"

local function data_key(task)
  local cached = task:get_mempool():get_variable("grey_bodyhash")
  if cached then
    lua_util.debugm(N, task, 'body key (cached): %s', cached)
    return cached
  end

  local body = task:get_rawbody()

  if not body then
    lua_util.debugm(N, task, 'no body, cannot generate body key')
    return nil
  end

  local len = body:len()
  if len > settings['max_data_len'] then
    len = settings['max_data_len']
  end

  local h = hash.create()
  h:update(body, len)
  local subject = task:get_subject() or ''
  h:update(subject)

  -- Take recipients into account
  local rcpt = task:get_recipients('smtp')
  if rcpt then
    table.sort(rcpt, function(r1, r2)
      return r1['addr'] < r2['addr']
    end)

    fun.each(function(r)
      h:update(r['addr'])
    end, rcpt)
  end

  -- Use from as well, but mime one
  local from = task:get_from('mime')

  local addr = '<>'
  if from and from[1] then
    addr = from[1]['addr']
  end
  h:update(addr)

  local b32 = settings['key_prefix'] .. 'b' .. h:base32():sub(1, 20)
  task:get_mempool():set_variable("grey_bodyhash", b32)
  lua_util.debugm(N, task, 'body key: %s (body_len=%s, subject=%s, from=%s, rcpt=%s)',
      b32, len, subject, addr, rcpt and #rcpt or 0)
  return b32
end

local function envelope_key(task)
  local cached = task:get_mempool():get_variable("grey_metahash")
  if cached then
    lua_util.debugm(N, task, 'meta key (cached): %s', cached)
    return cached
  end

  local from = task:get_from('smtp')
  local h = hash.create()

  local addr = '<>'
  if from and from[1] then
    addr = from[1]['addr']
  end

  h:update(addr)
  local rcpt = task:get_recipients('smtp')
  if rcpt then
    table.sort(rcpt, function(r1, r2)
      return r1['addr'] < r2['addr']
    end)

    fun.each(function(r)
      h:update(r['addr'])
    end, rcpt)
  end

  local ip = task:get_ip()
  local ip_str = 'none'

  if ip and ip:is_valid() then
    local s
    if ip:get_version() == 4 then
      s = tostring(ip:apply_mask(settings['ipv4_mask']))
    else
      s = tostring(ip:apply_mask(settings['ipv6_mask']))
    end
    h:update(s)
    ip_str = s
  end

  local b32 = settings['key_prefix'] .. 'm' .. h:base32():sub(1, 20)
  task:get_mempool():set_variable("grey_metahash", b32)
  lua_util.debugm(N, task, 'meta key: %s (from=%s, rcpt=%s, ip=%s)',
      b32, addr, rcpt and #rcpt or 0, ip_str)
  return b32
end

-- Returns pair of booleans: found,greylisted
local function check_time(task, tm, type, now)
  local t = tonumber(tm)

  if not t then
    rspamd_logger.errx(task, 'not a valid number: %s', tm)
    return false, false
  end

  local age = now - t
  if age < settings['timeout'] then
    lua_util.debugm(N, task, 'check_time(%s): record too young, age=%s, timeout=%s, greylisted',
        type, age, settings['timeout'])
    return true, true
  else
    -- We just set variable to pass when in post-filter stage
    task:get_mempool():set_variable("grey_whitelisted", type)
    lua_util.debugm(N, task, 'check_time(%s): record mature, age=%s, timeout=%s, whitelisted',
        type, age, settings['timeout'])

    return true, false
  end
end

local function greylist_message(task, end_time, why)
  task:insert_result(settings['symbol'], 0.0, 'greylisted', end_time, why)

  if not settings.check_local and rspamd_lua_utils.is_rspamc_or_controller(task) then
    return
  end

  if settings.message_func then
    task:set_pre_result(settings['action'],
        settings.message_func(task, end_time), N)
  else
    local message = settings['message']
    if settings.report_time then
      message = string.format("%s: %s", message, end_time)
    end
    task:set_pre_result(settings['action'], message, N)
  end

  task:set_flag('greylisted')
end

local function greylist_check(task)
  local ip = task:get_ip()

  if ((not settings.check_authed and task:get_user()) or
      (not settings.check_local and ip and ip:is_local())) then
    rspamd_logger.infox(task, "skip greylisting for local networks and/or authorized users");
    return
  end

  if ip and ip:is_valid() and whitelisted_ip then
    if whitelisted_ip:get_key(ip) then
      -- Do not check whitelisted ip
      rspamd_logger.infox(task, 'skip greylisting for whitelisted IP')
      return
    end
  end

  local body_key = data_key(task)
  local meta_key = envelope_key(task)
  local hash_key = body_key .. meta_key

  local function redis_get_cb(err, data)
    local ret_body = false
    local greylisted_body = false
    local ret_meta = false
    local greylisted_meta = false

    lua_util.debugm(N, task, 'redis_get_cb: body_key=%s, meta_key=%s, err=%s, data=%s',
        body_key, meta_key, err, data)

    if data then
      local end_time_body, end_time_meta
      local now = rspamd_util.get_time()

      lua_util.debugm(N, task, 'redis returned: body_val=%s (type=%s), meta_val=%s (type=%s)',
          data[1], type(data[1]), data[2], type(data[2]))

      if data[1] and type(data[1]) ~= 'userdata' then
        local tm = tonumber(data[1]) or now
        ret_body, greylisted_body = check_time(task, data[1], 'body', now)
        if greylisted_body then
          end_time_body = tm + settings['timeout']
          task:get_mempool():set_variable("grey_greylisted_body",
              rspamd_util.time_to_string(end_time_body))
        end
      end

      if data[2] and type(data[2]) ~= 'userdata' then
        if not ret_body or greylisted_body then
          local tm = tonumber(data[2]) or now
          ret_meta, greylisted_meta = check_time(task, data[2], 'meta', now)

          if greylisted_meta then
            end_time_meta = tm + settings['timeout']
            task:get_mempool():set_variable("grey_greylisted_meta",
                rspamd_util.time_to_string(end_time_meta))
          end
        end
      end

      local how
      local end_time_str

      if not ret_body and not ret_meta then
        -- no record found
        task:get_mempool():set_variable("grey_greylisted", 'true')
        lua_util.debugm(N, task, 'no existing record found, will greylist as new')
      elseif greylisted_body and greylisted_meta then
        end_time_str = rspamd_util.time_to_string(
            math.min(end_time_body, end_time_meta))
        how = 'meta and body'
      elseif greylisted_body then
        end_time_str = rspamd_util.time_to_string(end_time_body)
        how = 'body only'
      elseif greylisted_meta then
        end_time_str = rspamd_util.time_to_string(end_time_meta)
        how = 'meta only'
      else
        lua_util.debugm(N, task, 'record found and mature: ret_body=%s, ret_meta=%s',
            ret_body, ret_meta)
      end

      if how and end_time_str then
        rspamd_logger.infox(task, 'greylisted until "%s" (%s)',
            end_time_str, how)
        greylist_message(task, end_time_str, 'too early')
      end
    elseif err then
      rspamd_logger.errx(task, 'got error while getting greylisting keys: %1', err)
      return
    end
  end

  lua_util.debugm(N, task, 'greylist_check: MGET %s %s', body_key, meta_key)
  local ret = lua_redis.redis_make_request(task,
      redis_params, -- connect params
      hash_key, -- hash key
      false, -- is write
      redis_get_cb, --callback
      'MGET', -- command
      { body_key, meta_key } -- arguments
  )
  if not ret then
    rspamd_logger.errx(task, 'cannot make redis request to check results')
  end
end

local function greylist_set(task)
  local action = task:get_metric_action()
  local ip = task:get_ip()

  lua_util.debugm(N, task, 'greylist_set: action=%s, ip=%s', action, ip)

  -- Don't do anything if pre-result has been already set
  if task:has_pre_result() then
    lua_util.debugm(N, task, 'greylist_set: pre-result already set, skipping')
    return
  end

  -- Check whitelist_symbols
  for _, sym in ipairs(settings.whitelist_symbols) do
    if task:has_symbol(sym) then
      rspamd_logger.infox(task, 'skip greylisting as we have found symbol %s', sym)
      if action == 'greylist' then
        -- We are going to accept message
        rspamd_logger.infox(task, 'downgrading metric action from "greylist" to "no action"')
        task:disable_action('greylist')
      end
      return
    end
  end

  -- We need to update this on each scan, as it can vary per settings or be redefined dynamically
  local greylist_min_score = settings.greylist_min_score or task:get_metric_threshold('greylist')
  if greylist_min_score then
    local score = task:get_metric_score()[1]
    if score < greylist_min_score then
      rspamd_logger.infox(task, 'Score too low - skip greylisting')
      if action == 'greylist' then
        -- We are going to accept message
        rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
        task:disable_action('greylist')
      end
      return
    end
  end

  if ((not settings.check_authed and task:get_user()) or
      (not settings.check_local and ip and ip:is_local())) then
    if action == 'greylist' then
      -- We are going to accept message
      rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
      task:disable_action('greylist')
    end
    return
  end

  if ip and ip:is_valid() and whitelisted_ip then
    if whitelisted_ip:get_key(ip) then
      if action == 'greylist' then
        -- We are going to accept message
        rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
        task:disable_action('greylist')
      end
      return
    end
  end

  local is_whitelisted = task:get_mempool():get_variable("grey_whitelisted")
  local do_greylisting = task:get_mempool():get_variable("grey_greylisted")
  local do_greylisting_required = task:get_mempool():get_variable("grey_greylisted_required")

  lua_util.debugm(N, task, 'greylist_set: is_whitelisted=%s, do_greylisting=%s, do_greylisting_required=%s',
      is_whitelisted, do_greylisting, do_greylisting_required)

  -- Third and second level domains whitelist
  if not is_whitelisted and whitelist_domains_map then
    local hostname = task:get_hostname()
    if hostname then
      local domain = rspamd_util.get_tld(hostname)
      if whitelist_domains_map:get_key(hostname) or (domain and whitelist_domains_map:get_key(domain)) then
        is_whitelisted = 'meta'
        rspamd_logger.infox(task, 'skip greylisting for whitelisted domain')
      end
    end
  end

  if action == 'reject' or
      not do_greylisting_required and action == 'no action' then
    return
  end
  local body_key = data_key(task)
  local meta_key = envelope_key(task)
  local upstream, ret, conn
  local hash_key = body_key .. meta_key

  local function redis_set_cb(err)
    if err then
      rspamd_logger.errx(task, 'got error %s when setting greylisting record on server %s',
          err, upstream:get_addr())
    else
      lua_util.debugm(N, task, 'redis_set_cb: successfully set greylisting record')
    end
  end

  local is_rspamc = rspamd_lua_utils.is_rspamc_or_controller(task)

  if is_whitelisted then
    lua_util.debugm(N, task, 'greylist_set: whitelisted by %s, extending expire', is_whitelisted)
    if action == 'greylist' then
      -- We are going to accept message
      rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
      task:disable_action('greylist')
    end

    task:insert_result(settings['symbol'], 0.0, 'pass', is_whitelisted)
    rspamd_logger.infox(task, 'greylisting pass (%s) until %s',
        is_whitelisted,
        rspamd_util.time_to_string(rspamd_util.get_time() + settings['expire']))

    if not settings.check_local and is_rspamc then
      return
    end

    lua_util.debugm(N, task, 'greylist_set: EXPIRE %s %s', body_key, settings['expire'])
    ret, conn, upstream = lua_redis.redis_make_request(task,
        redis_params, -- connect params
        hash_key, -- hash key
        true, -- is write
        redis_set_cb, --callback
        'EXPIRE', -- command
        { body_key, tostring(toint(settings['expire'])) } -- arguments
    )
    -- Update greylisting record expire
    if ret then
      lua_util.debugm(N, task, 'greylist_set: EXPIRE %s %s', meta_key, settings['expire'])
      conn:add_cmd('EXPIRE', {
        meta_key, tostring(toint(settings['expire']))
      })
    else
      rspamd_logger.errx(task, 'got error while connecting to redis')
    end
  elseif do_greylisting or do_greylisting_required then
    if not settings.check_local and is_rspamc then
      return
    end
    local t = tostring(toint(rspamd_util.get_time()))
    local end_time = rspamd_util.time_to_string(t + settings['timeout'])
    rspamd_logger.infox(task, 'greylisted until "%s", new record', end_time)
    greylist_message(task, end_time, 'new record')
    -- Create new record
    lua_util.debugm(N, task, 'greylist_set: SETEX %s %s %s (new record)',
        body_key, settings['expire'], t)
    ret, conn, upstream = lua_redis.redis_make_request(task,
        redis_params, -- connect params
        hash_key, -- hash key
        true, -- is write
        redis_set_cb, --callback
        'SETEX', -- command
        { body_key, tostring(toint(settings['expire'])), t } -- arguments
    )

    if ret then
      lua_util.debugm(N, task, 'greylist_set: SETEX %s %s %s',
          meta_key, settings['expire'], t)
      conn:add_cmd('SETEX', {
        meta_key, tostring(toint(settings['expire'])), t
      })
    else
      rspamd_logger.errx(task, 'got error while connecting to redis')
    end
  else
    if action ~= 'no action' and action ~= 'reject' then
      local grey_res = task:get_mempool():get_variable("grey_greylisted_body")

      if grey_res then
        -- We need to delay message, hence set a temporary result
        rspamd_logger.infox(task, 'greylisting delayed until "%s": body', grey_res)
        greylist_message(task, grey_res, 'body')
      else
        grey_res = task:get_mempool():get_variable("grey_greylisted_meta")
        if grey_res then
          greylist_message(task, grey_res, 'meta')
        end
      end
    else
      task:insert_result(settings['symbol'], 0.0, 'greylisted', 'passed')
    end
  end
end

local opts = rspamd_config:get_all_opt('greylist')
if opts then
  if opts['message_func'] then
    settings.message_func = assert(load(opts['message_func']))()
  end

  for k, v in pairs(opts) do
    if k ~= 'message_func' then
      settings[k] = v
    end
  end

  local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
      false, false)
  settings.check_local = auth_and_local_conf[1]
  settings.check_authed = auth_and_local_conf[2]

  if settings['greylist_min_score'] then
    settings['greylist_min_score'] = tonumber(settings['greylist_min_score'])
  end

  whitelisted_ip = lua_map.rspamd_map_add(N, 'whitelisted_ip', 'radix',
      'Greylist whitelist ip map')
  whitelist_domains_map = lua_map.rspamd_map_add(N, 'whitelist_domains_url',
      'map', 'Greylist whitelist domains map')

  redis_params = lua_redis.parse_redis_server(N)
  if not redis_params then
    rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
    rspamd_lua_utils.disable_module(N, "redis")
  else
    lua_redis.register_prefix(settings.key_prefix .. 'b[a-z0-9]{20}', N,
        'Greylisting elements (body hashes)"', {
          type = 'string',
        })
    lua_redis.register_prefix(settings.key_prefix .. 'm[a-z0-9]{20}', N,
        'Greylisting elements (meta hashes)"', {
          type = 'string',
        })
    rspamd_config:register_symbol({
      name = 'GREYLIST_SAVE',
      type = 'postfilter',
      callback = greylist_set,
      priority = lua_util.symbols_priorities.medium,
      augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
    })
    local id = rspamd_config:register_symbol({
      name = 'GREYLIST_CHECK',
      type = 'prefilter',
      callback = greylist_check,
      priority = lua_util.symbols_priorities.medium,
      augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
    })
    rspamd_config:register_symbol({
      name = settings.symbol,
      type = 'virtual',
      parent = id,
      score = 0,
    })
  end
end
