--[[ Limristem eMail — per-mailbox authenticated send quotas. Reads resolved limits from Redis key: limristem:sendlim:cfg: with fallback to: limristem:sendlim:cfg:__global__ Counters (auto-expire): limristem:sendlim:c::: Only applies to authenticated SMTP sessions (task:get_user()). ]] local rspamd_logger = require "rspamd_logger" local lua_util = require "lua_util" local lua_redis = require "lua_redis" local rspamd_util = require "rspamd_util" local ucl = require "ucl" local N = "limristem_user_send_limits" local redis_params local settings = { symbol = "LIMRISTEM_USER_SEND_LIMIT", message = "Sending rate limit exceeded for this mailbox; try again later", enabled = true, } local windows = { { name = "minute", field = "send_rate_per_minute", ttl = 60 }, { name = "hour", field = "send_rate_per_hour", ttl = 3600 }, { name = "day", field = "send_rate_per_day", ttl = 86400 }, { name = "week", field = "send_rate_per_week", ttl = 604800 }, { name = "month", field = "send_rate_per_month", ttl = 2592000 }, } local function normalize_limits(tbl) if type(tbl) ~= "table" then return nil end local out = {} local map = { burst = "send_rate_burst", send_rate_burst = "send_rate_burst", minute = "send_rate_per_minute", send_rate_per_minute = "send_rate_per_minute", hour = "send_rate_per_hour", send_rate_per_hour = "send_rate_per_hour", day = "send_rate_per_day", send_rate_per_day = "send_rate_per_day", week = "send_rate_per_week", send_rate_per_week = "send_rate_per_week", month = "send_rate_per_month", send_rate_per_month = "send_rate_per_month", } for k, v in pairs(tbl) do local target_k = map[k] if target_k then out[target_k] = tonumber(v) or 0 elseif k == "disabled" then out.disabled = (v == true or v == "1" or v == 1 or v == "true") end end if next(out) then return out end return nil end local function parse_limits(raw) if not raw or type(raw) ~= "string" or raw == "" then return nil end local parser = ucl.parser() local ok = parser:parse_string(raw) if ok then local data = parser:get_object() local normalized = normalize_limits(data) if normalized then return normalized end end local map_tbl = {} for pair in string.gmatch(raw, "[^;]+") do local k, v = string.match(pair, "^%s*([%w_]+)%s*=%s*([%w%d]+)%s*$") if k and v then map_tbl[k] = v end end return normalize_limits(map_tbl) end local function bucket_id(ttl) return math.floor(os.time() / ttl) end local function reject_task(task, reason) task:insert_result(settings.symbol, 1.0, reason) task:set_pre_result("soft reject", settings.message .. " (" .. reason .. ")") end local function check_window(task, user, limits, window, cont) local limit = tonumber(limits[window.field] or 0) or 0 if limit <= 0 then cont() return end local b = bucket_id(window.ttl) local key = string.format("limristem:sendlim:c:%s:%s:%s", user, window.name, b) local function redis_cb(err, data) if err then rspamd_logger.infox(task, "%s: redis counter error for %s: %s", N, window.name, err) cont() return end local count = tonumber(data) or 0 if count > limit then reject_task(task, string.format("%s %s/%s", window.name, count, limit)) return end cont() end local ret = lua_redis.redis_make_request(task, redis_params, key, true, redis_cb, "INCR", { key }) if not ret then cont() return end -- Set TTL best-effort after INCR lua_redis.redis_make_request(task, redis_params, key, true, function() end, "EXPIRE", { key, tostring(window.ttl + 5) }) end local function enforce(task, user, limits) if limits.disabled == true or limits.disabled == 1 or limits.disabled == "1" then reject_task(task, "mailbox disabled") return end local idx = 1 local function next_window() local window = windows[idx] if not window then return end idx = idx + 1 check_window(task, user, limits, window, next_window) end next_window() end local function limits_loaded(task, user, err, data) if err then rspamd_logger.infox(task, "%s: redis cfg error: %s", N, err) return end local limits = parse_limits(data) if not limits then -- Fallback to global profile local gkey = "limristem:sendlim:cfg:__global__" local function global_cb(gerr, gdata) if gerr then return end local glimits = parse_limits(gdata) if glimits then enforce(task, user, glimits) end end lua_redis.redis_make_request(task, redis_params, gkey, false, global_cb, "GET", { gkey }) return end enforce(task, user, limits) end local function check_cb(task) if not settings.enabled or not redis_params then return end local user = task:get_user() if not user or user == "" then return end user = string.lower(user) local key = "limristem:sendlim:cfg:" .. user local function redis_cb(err, data) limits_loaded(task, user, err, data) end local ret = lua_redis.redis_make_request(task, redis_params, key, false, redis_cb, "GET", { key }) if not ret then rspamd_logger.infox(task, "%s: cannot schedule redis GET for %s", N, user) end end redis_params = lua_redis.parse_redis_server("limristem_user_send_limits") if not redis_params then redis_params = lua_redis.parse_redis_server("ratelimit") end if not redis_params then -- Fall back to default redis configuration from redis.conf redis_params = lua_redis.parse_redis_server("redis") end if redis_params then rspamd_config:register_symbol({ name = settings.symbol, type = "postfilter", priority = 20, callback = check_cb, flags = "empty", }) rspamd_logger.infox(rspamd_config, "%s: registered authenticated send quota enforcer", N) else rspamd_logger.warnx(rspamd_config, "%s: redis not configured; per-mailbox send limits disabled", N) end