--[[ 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 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 parse_limits(raw) if not raw or raw == "" then return nil end local ok, data = pcall(rspamd_util.parse_json, raw) if ok and type(data) == "table" then return data end -- Fallback map-style: burst=;minute=;hour=... local out = {} 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 if k == "burst" then out.send_rate_burst = tonumber(v) or 0 elseif k == "minute" then out.send_rate_per_minute = tonumber(v) or 0 elseif k == "hour" then out.send_rate_per_hour = tonumber(v) or 0 elseif k == "day" then out.send_rate_per_day = tonumber(v) or 0 elseif k == "week" then out.send_rate_per_week = tonumber(v) or 0 elseif k == "month" then out.send_rate_per_month = tonumber(v) or 0 elseif k == "disabled" then out.disabled = (v == "1" or v == "true") end end end if next(out) then return out end return nil 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