diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 503b41e6d..0be7789bb 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -1,10 +1,10 @@ rspamd_config.MAILCOW_AUTH = { - callback = function(task) - local uname = task:get_user() - if uname then - return 1 - end - end + callback = function(task) + local uname = task:get_user() + if uname then + return 1 + end + end } local monitoring_hosts = rspamd_config:add_map{ @@ -73,12 +73,7 @@ rspamd_config:register_symbol({ end table.insert(smtp_access_table, 1, hash_key) local redis_ret_user = rspamd_redis_make_request(task, - redis_params, -- connect params - hash_key, -- hash key - false, -- is write - smtp_access_cb, --callback - 'HMGET', -- command - smtp_access_table -- arguments + redis_params, hash_key, false, smtp_access_cb, 'HMGET', smtp_access_table ) if not redis_ret_user then rspamd_logger.infox(rspamd_config, "cannot check smtp_access redis map") @@ -91,37 +86,34 @@ rspamd_config:register_symbol({ name = 'POSTMASTER_HANDLER', type = 'prefilter', callback = function(task) - local rcpts = task:get_recipients('smtp') - local rspamd_logger = require "rspamd_logger" - local lua_util = require "lua_util" - local from = task:get_from(1) + local rcpts = task:get_recipients('smtp') + local rspamd_logger = require "rspamd_logger" + local lua_util = require "lua_util" + local from = task:get_from(1) - -- not applying to mails with more than one rcpt to avoid bypassing filters by addressing postmaster - if rcpts and #rcpts == 1 then - for _,rcpt in ipairs(rcpts) do - local rcpt_split = rspamd_str_split(rcpt['addr'], '@') - if #rcpt_split == 2 then - if rcpt_split[1] == 'postmaster' then - task:set_pre_result('accept', 'whitelisting postmaster smtp rcpt', 'postmaster') - return + if rcpts and #rcpts == 1 then + for _,rcpt in ipairs(rcpts) do + local rcpt_split = rspamd_str_split(rcpt['addr'], '@') + if #rcpt_split == 2 then + if rcpt_split[1] == 'postmaster' then + task:set_pre_result('accept', 'whitelisting postmaster smtp rcpt', 'postmaster') + return + end end end end - end - if from then - for _,fr in ipairs(from) do - local fr_split = rspamd_str_split(fr['addr'], '@') - if #fr_split == 2 then - if fr_split[1] == 'postmaster' and task:get_user() then - -- no whitelist, keep signatures - task:insert_result(true, 'POSTMASTER_FROM', -2500.0) - return + if from then + for _,fr in ipairs(from) do + local fr_split = rspamd_str_split(fr['addr'], '@') + if #fr_split == 2 then + if fr_split[1] == 'postmaster' and task:get_user() then + task:insert_result(true, 'POSTMASTER_FROM', -2500.0) + return + end end end end - end - end, priority = 10 }) @@ -146,171 +138,8 @@ rspamd_config:register_symbol({ return false end - -- Helper function to parse IPv6 into 8 segments - local function ipv6_to_segments(ip_str) - -- Remove zone identifier if present (e.g., %eth0) - ip_str = ip_str:gsub("%%.*$", "") - - local segments = {} - - -- Handle :: compression - if ip_str:find('::') then - local before, after = ip_str:match('^(.*)::(.*)$') - before = before or '' - after = after or '' - - local before_parts = {} - local after_parts = {} - - if before ~= '' then - for seg in before:gmatch('[^:]+') do - table.insert(before_parts, tonumber(seg, 16) or 0) - end - end - - if after ~= '' then - for seg in after:gmatch('[^:]+') do - table.insert(after_parts, tonumber(seg, 16) or 0) - end - end - - -- Add before segments - for _, seg in ipairs(before_parts) do - table.insert(segments, seg) - end - - -- Add compressed zeros - local zeros_needed = 8 - #before_parts - #after_parts - for i = 1, zeros_needed do - table.insert(segments, 0) - end - - -- Add after segments - for _, seg in ipairs(after_parts) do - table.insert(segments, seg) - end - else - -- No compression - for seg in ip_str:gmatch('[^:]+') do - table.insert(segments, tonumber(seg, 16) or 0) - end - end - - -- Ensure we have exactly 8 segments - while #segments < 8 do - table.insert(segments, 0) - end - - return segments - end - - -- Generate all common IPv6 notations - local function get_ipv6_variants(ip_str) - local variants = {} - local seen = {} - - local function add_variant(v) - if v and not seen[v] then - table.insert(variants, v) - seen[v] = true - end - end - - -- For IPv4, just return the original - if not ip_str:find(':') then - add_variant(ip_str) - return variants - end - - local segments = ipv6_to_segments(ip_str) - - -- 1. Fully expanded form (all zeros shown as 0000) - local expanded_parts = {} - for _, seg in ipairs(segments) do - table.insert(expanded_parts, string.format('%04x', seg)) - end - add_variant(table.concat(expanded_parts, ':')) - - -- 2. Standard form (no leading zeros, but all segments present) - local standard_parts = {} - for _, seg in ipairs(segments) do - table.insert(standard_parts, string.format('%x', seg)) - end - add_variant(table.concat(standard_parts, ':')) - - -- 3. Find all possible :: compressions - -- RFC 5952: compress the longest run of consecutive zeros - -- But we need to check all possibilities since Redis might have any form - - -- Find all zero runs - local zero_runs = {} - local in_run = false - local run_start = 0 - local run_length = 0 - - for i = 1, 8 do - if segments[i] == 0 then - if not in_run then - in_run = true - run_start = i - run_length = 1 - else - run_length = run_length + 1 - end - else - if in_run then - if run_length >= 1 then -- Allow single zero compression too - table.insert(zero_runs, {start = run_start, length = run_length}) - end - in_run = false - end - end - end - - -- Don't forget the last run - if in_run and run_length >= 1 then - table.insert(zero_runs, {start = run_start, length = run_length}) - end - - -- Generate variant for each zero run compression - for _, run in ipairs(zero_runs) do - local parts = {} - - -- Before compression - for i = 1, run.start - 1 do - table.insert(parts, string.format('%x', segments[i])) - end - - -- The compression - if run.start == 1 then - table.insert(parts, '') - table.insert(parts, '') - elseif run.start + run.length - 1 == 8 then - table.insert(parts, '') - table.insert(parts, '') - else - table.insert(parts, '') - end - - -- After compression - for i = run.start + run.length, 8 do - table.insert(parts, string.format('%x', segments[i])) - end - - local compressed = table.concat(parts, ':'):gsub('::+', '::') - add_variant(compressed) - end - - return variants - end - local from_ip_string = tostring(ip) - local ip_check_table = {} - - -- Add all variants of the exact IP - for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do - table.insert(ip_check_table, variant) - end + ip_check_table = {from_ip_string} local maxbits = 128 local minbits = 32 @@ -318,18 +147,10 @@ rspamd_config:register_symbol({ maxbits = 32 minbits = 8 end - - -- Add all CIDR notations with variants for i=maxbits,minbits,-1 do - local masked_ip = ip:apply_mask(i) - local cidr_base = masked_ip:to_string() - - for _, variant in ipairs(get_ipv6_variants(cidr_base)) do - local cidr = variant .. "/" .. i - table.insert(ip_check_table, cidr) - end + local nip = ip:apply_mask(i):to_string() .. "/" .. i + table.insert(ip_check_table, nip) end - local function keep_spam_cb(err, data) if err then rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err) @@ -337,23 +158,15 @@ rspamd_config:register_symbol({ else for k,v in pairs(data) do if (v and v ~= userdata and v == '1') then - rspamd_logger.infox(rspamd_config, "found ip %s (checked as: %s) in keep_spam map, setting pre-result accept", from_ip_string, ip_check_table[k]) + rspamd_logger.infox(rspamd_config, "found ip in keep_spam map, setting pre-result") task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam') - task:set_flag('no_stat') - return end end end end - table.insert(ip_check_table, 1, 'KEEP_SPAM') local redis_ret_user = rspamd_redis_make_request(task, - redis_params, -- connect params - 'KEEP_SPAM', -- hash key - false, -- is write - keep_spam_cb, --callback - 'HMGET', -- command - ip_check_table -- arguments + redis_params, 'KEEP_SPAM', false, keep_spam_cb, 'HMGET', ip_check_table ) if not redis_ret_user then rspamd_logger.infox(rspamd_config, "cannot check keep_spam redis map") @@ -384,7 +197,6 @@ rspamd_config:register_symbol({ rspamd_config:register_symbol({ name = 'TAG_MOO', type = 'postfilter', - flags = 'ignore_passthrough', callback = function(task) local util = require("rspamd_util") local rspamd_logger = require "rspamd_logger" @@ -393,6 +205,9 @@ rspamd_config:register_symbol({ local rcpts = task:get_recipients('smtp') local lua_util = require "lua_util" + local tagged_rcpt = task:get_symbol("TAGGED_RCPT") + local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN") + local function remove_moo_tag() local moo_tag_header = task:get_header('X-Moo-Tag', false) if moo_tag_header then @@ -403,149 +218,91 @@ rspamd_config:register_symbol({ return true end - -- Check if we have exactly one recipient - if not (rcpts and #rcpts == 1) then - rspamd_logger.infox("TAG_MOO: not exactly one rcpt (%s), removing moo tag", rcpts and #rcpts or 0) - remove_moo_tag() - return - end + if tagged_rcpt and tagged_rcpt[1].options and mailcow_domain then + local tag = tagged_rcpt[1].options[1] + rspamd_logger.infox("found tag: %s", tag) + local action = task:get_metric_action('default') + rspamd_logger.infox("metric action now: %s", action) - local rcpt_addr = rcpts[1]['addr'] - local rcpt_user = rcpts[1]['user'] - local rcpt_domain = rcpts[1]['domain'] - - -- Check if recipient has a tag (contains '+') - local tag = nil - if rcpt_user:find('%+') then - local base_user, tag_part = rcpt_user:match('^(.-)%+(.+)$') - if base_user and tag_part then - tag = tag_part - rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag) + if action ~= 'no action' and action ~= 'greylist' then + rspamd_logger.infox("skipping tag handler for action: %s", action) + remove_moo_tag() + return true end - end - if not tag then - rspamd_logger.infox("TAG_MOO: no tag found in recipient %s, removing moo tag", rcpt_addr) - remove_moo_tag() - return - end + local function http_callback(err_message, code, body, headers) + if body ~= nil and body ~= "" then + rspamd_logger.infox(rspamd_config, "expanding rcpt to \"%s\"", body) - -- Optional: Check if domain is a mailcow domain - -- When KEEP_SPAM is active, RCPT_MAILCOW_DOMAIN might not be set - -- If the mail is being delivered, we can assume it's valid - local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN") - if not mailcow_domain then - rspamd_logger.infox("TAG_MOO: RCPT_MAILCOW_DOMAIN not set (possibly due to pre-result), proceeding anyway for domain %s", rcpt_domain) - end + local function tag_callback_subject(err, data) + if err or type(data) ~= 'string' then + rspamd_logger.infox(rspamd_config, "subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err) - local action = task:get_metric_action('default') - rspamd_logger.infox("TAG_MOO: metric action: %s", action) + local function tag_callback_subfolder(err, data) + if err or type(data) ~= 'string' then + rspamd_logger.infox(rspamd_config, "subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err) + remove_moo_tag() + else + rspamd_logger.infox("Add X-Moo-Tag header") + task:set_milter_reply({ + add_headers = {['X-Moo-Tag'] = 'YES'} + }) + end + end + + local redis_ret_subfolder = rspamd_redis_make_request(task, + redis_params, body, false, tag_callback_subfolder, 'HGET', {'RCPT_WANTS_SUBFOLDER_TAG', body} + ) + if not redis_ret_subfolder then + rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt") + remove_moo_tag() + end + + else + rspamd_logger.infox("user wants subject modified for tagged mail") + local sbj = task:get_header('Subject') + new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' + task:set_milter_reply({ + remove_headers = { + ['Subject'] = 1, + ['X-Moo-Tag'] = 0 + }, + add_headers = {['Subject'] = new_sbj} + }) + end + end + + local redis_ret_subject = rspamd_redis_make_request(task, + redis_params, body, false, tag_callback_subject, 'HGET', {'RCPT_WANTS_SUBJECT_TAG', body} + ) + if not redis_ret_subject then + rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt") + remove_moo_tag() + end - -- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER) - local allow_processing = false - - if task.has_pre_result then - local has_pre, pre_action = task:has_pre_result() - if has_pre then - rspamd_logger.infox("TAG_MOO: pre-result detected: %s", tostring(pre_action)) - if pre_action == 'accept' then - allow_processing = true - rspamd_logger.infox("TAG_MOO: pre-result is accept, will process") end end - end - -- Allow processing for mild actions or when we have pre-result accept - if not allow_processing and action ~= 'no action' and action ~= 'greylist' then - rspamd_logger.infox("TAG_MOO: skipping tag handler for action: %s", action) - remove_moo_tag() - return true - end - - rspamd_logger.infox("TAG_MOO: processing allowed") - - local function http_callback(err_message, code, body, headers) - if body ~= nil and body ~= "" then - rspamd_logger.infox(rspamd_config, "TAG_MOO: expanding rcpt to \"%s\"", body) - - local function tag_callback_subject(err, data) - if err or type(data) ~= 'string' or data == '' then - rspamd_logger.infox(rspamd_config, "TAG_MOO: subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err) - - local function tag_callback_subfolder(err, data) - if err or type(data) ~= 'string' or data == '' then - rspamd_logger.infox(rspamd_config, "TAG_MOO: subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err) - remove_moo_tag() - else - rspamd_logger.infox("TAG_MOO: User wants subfolder tag, adding X-Moo-Tag header") - task:set_milter_reply({ - add_headers = {['X-Moo-Tag'] = 'YES'} - }) - end - end - - local redis_ret_subfolder = rspamd_redis_make_request(task, - redis_params, -- connect params - body, -- hash key - false, -- is write - tag_callback_subfolder, --callback - 'HGET', -- command - {'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments - ) - if not redis_ret_subfolder then - rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") + if rcpts and #rcpts == 1 then + for _,rcpt in ipairs(rcpts) do + local rcpt_split = rspamd_str_split(rcpt['addr'], '@') + if #rcpt_split == 2 then + if rcpt_split[1] == 'postmaster' then + rspamd_logger.infox(rspamd_config, "not expanding postmaster alias") remove_moo_tag() + else + rspamd_http.request({ + task=task, + url='http://nginx:8081/aliasexp.php', + body='', + callback=http_callback, + headers={Rcpt=rcpt['addr']}, + }) end - - else - rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") - local sbj = task:get_header('Subject') or '' - new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' - task:set_milter_reply({ - remove_headers = { - ['Subject'] = 1, - ['X-Moo-Tag'] = 0 - }, - add_headers = {['Subject'] = new_sbj} - }) end end - - local redis_ret_subject = rspamd_redis_make_request(task, - redis_params, -- connect params - body, -- hash key - false, -- is write - tag_callback_subject, --callback - 'HGET', -- command - {'RCPT_WANTS_SUBJECT_TAG', body} -- arguments - ) - if not redis_ret_subject then - rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") - remove_moo_tag() - end - else - rspamd_logger.infox("TAG_MOO: alias expansion returned empty body") - remove_moo_tag() - end - end - - local rcpt_split = rspamd_str_split(rcpt_addr, '@') - if #rcpt_split == 2 then - if rcpt_split[1]:match('^postmaster') then - rspamd_logger.infox(rspamd_config, "TAG_MOO: not expanding postmaster alias") - remove_moo_tag() - else - rspamd_logger.infox("TAG_MOO: requesting alias expansion for %s", rcpt_addr) - rspamd_http.request({ - task=task, - url='http://nginx:8081/aliasexp.php', - body='', - callback=http_callback, - headers={Rcpt=rcpt_addr}, - }) end else - rspamd_logger.infox("TAG_MOO: invalid rcpt format") remove_moo_tag() end end, @@ -555,7 +312,6 @@ rspamd_config:register_symbol({ rspamd_config:register_symbol({ name = 'BCC', type = 'postfilter', - flags = 'ignore_passthrough', callback = function(task) local util = require("rspamd_util") local rspamd_http = require "rspamd_http" @@ -565,7 +321,7 @@ rspamd_config:register_symbol({ local rcpt_table = {} if task:has_symbol('ENCRYPTED_CHAT') then - return -- stop + return end local send_mail = function(task, bcc_dest) @@ -578,83 +334,63 @@ rspamd_config:register_symbol({ end end if not bcc_dest then - return -- stop + return end - -- dot stuff content before sending local email_content = tostring(task:get_content()) email_content = string.gsub(email_content, "\r\n%.", "\r\n..") - -- send mail - local from_smtp = task:get_from('smtp') - local from_addr = (from_smtp and from_smtp[1] and from_smtp[1].addr) or 'mailer-daemon@localhost' lua_smtp.sendmail({ task = task, host = os.getenv("IPV4_NETWORK") .. '.253', port = 591, - from = from_addr, + from = task:get_from(stp)[1].addr, recipients = bcc_dest, helo = 'bcc', timeout = 20, }, email_content, sendmail_cb) end - -- determine from local from = task:get_from('smtp') if from then for _, a in ipairs(from) do - table.insert(from_table, a['addr']) -- add this rcpt to table - table.insert(from_table, '@' .. a['domain']) -- add this rcpts domain to table + table.insert(from_table, a['addr']) + table.insert(from_table, '@' .. a['domain']) end else - return -- stop - end - - -- determine rcpts - local rcpts = task:get_recipients('smtp') - if rcpts then - for _, a in ipairs(rcpts) do - table.insert(rcpt_table, a['addr']) -- add this rcpt to table - table.insert(rcpt_table, '@' .. a['domain']) -- add this rcpts domain to table - end - else - return -- stop - end - - local action = task:get_metric_action('default') - rspamd_logger.infox("BCC: metric action: %s", action) - - -- Check for pre-result accept (e.g., from KEEP_SPAM) - local allow_bcc = false - if task.has_pre_result then - local has_pre, pre_action = task:has_pre_result() - if has_pre and pre_action == 'accept' then - allow_bcc = true - rspamd_logger.infox("BCC: pre-result accept detected, will send BCC") - end - end - - -- Allow BCC for mild actions or when we have pre-result accept - if not allow_bcc and action ~= 'no action' and action ~= 'add header' and action ~= 'rewrite subject' then - rspamd_logger.infox("BCC: skipping for action: %s", action) return end + local rcpts = task:get_recipients('smtp') + if rcpts then + for _, a in ipairs(rcpts) do + table.insert(rcpt_table, a['addr']) + table.insert(rcpt_table, '@' .. a['domain']) + end + else + return + end + + local action = task:get_metric_action('default') + rspamd_logger.infox("metric action now: %s", action) + local function rcpt_callback(err_message, code, body, headers) if err_message == nil and code == 201 and body ~= nil then - rspamd_logger.infox("BCC: sending BCC to %s for rcpt match", body) - send_mail(task, body) + if action == 'no action' or action == 'add header' or action == 'rewrite subject' then + send_mail(task, body) + end end end local function from_callback(err_message, code, body, headers) if err_message == nil and code == 201 and body ~= nil then - rspamd_logger.infox("BCC: sending BCC to %s for from match", body) - send_mail(task, body) + if action == 'no action' or action == 'add header' or action == 'rewrite subject' then + send_mail(task, body) + end end end if rcpt_table then for _,e in ipairs(rcpt_table) do - rspamd_logger.infox(rspamd_config, "BCC: checking bcc for rcpt address %s", e) + rspamd_logger.infox(rspamd_config, "checking bcc for rcpt address %s", e) rspamd_http.request({ task=task, url='http://nginx:8081/bcc.php', @@ -667,7 +403,7 @@ rspamd_config:register_symbol({ if from_table then for _,e in ipairs(from_table) do - rspamd_logger.infox(rspamd_config, "BCC: checking bcc for from address %s", e) + rspamd_logger.infox(rspamd_config, "checking bcc for from address %s", e) rspamd_http.request({ task=task, url='http://nginx:8081/bcc.php', @@ -678,7 +414,7 @@ rspamd_config:register_symbol({ end end - -- Don't return true to avoid symbol being logged + return true end, priority = 20 }) @@ -703,7 +439,7 @@ rspamd_config:register_symbol({ return false end - local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case + local env_from_domain = envfrom[1].domain:lower() local function redis_cb_user(err, data) @@ -720,12 +456,7 @@ rspamd_config:register_symbol({ end local redis_ret_domain = rspamd_redis_make_request(task, - redis_params, -- connect params - env_from_domain, -- hash key - false, -- is write - redis_key_cb_domain, --callback - 'HGET', -- command - {'RL_VALUE', env_from_domain} -- arguments + redis_params, env_from_domain, false, redis_key_cb_domain, 'HGET', {'RL_VALUE', env_from_domain} ) if not redis_ret_domain then rspamd_logger.infox(rspamd_config, "cannot make request to load ratelimit for domain") @@ -738,12 +469,7 @@ rspamd_config:register_symbol({ end local redis_ret_user = rspamd_redis_make_request(task, - redis_params, -- connect params - uname, -- hash key - false, -- is write - redis_cb_user, --callback - 'HGET', -- command - {'RL_VALUE', uname} -- arguments + redis_params, uname, false, redis_cb_user, 'HGET', {'RL_VALUE', uname} ) if not redis_ret_user then rspamd_logger.infox(rspamd_config, "cannot make request to load ratelimit for user") @@ -784,31 +510,76 @@ rspamd_config:register_symbol({ local env_from_domain = envfrom[1].domain:lower() local env_from_addr = envfrom[1].addr:lower() - -- determine newline type local function newline(task) local t = task:get_newlines_type() - - if t == 'cr' then - return '\r' - elseif t == 'lf' then - return '\n' - end - + if t == 'cr' then return '\r' elseif t == 'lf' then return '\n' end return '\r\n' end - -- retrieve footer - local function footer_cb(err_message, code, data, headers) - if err or type(data) ~= 'string' then - rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err) - else - -- parse json string + -- NEW: Analyze MIME structure complexity + local function analyze_mime_structure(task) + local parts = task:get_parts() + if not parts or #parts == 0 then + return {simple = true, depth = 0, is_complex = false} + end + local max_depth = 0 + local has_related = false + local has_mixed = false + local has_inline_images = false + local has_attachments = false + for _, part in ipairs(parts) do + if part:is_multipart() then + local _, msubtype = part:get_type() + if msubtype == 'related' then has_related = true end + if msubtype == 'mixed' then has_mixed = true end + end + if part:is_attachment() then + has_attachments = true + end + if part:is_image() then + local cd = part:get_header('Content-Disposition') + if cd and cd:lower():match('inline') then + has_inline_images = true + end + end + end + local is_complex = (has_related and has_mixed and has_attachments) + return { + has_related = has_related, + has_mixed = has_mixed, + has_inline_images = has_inline_images, + has_attachments = has_attachments, + is_complex = is_complex + } + end + + local function footer_cb(err_message, code, data, headers) + if err_message or type(data) ~= 'string' then + rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err_message) + else local footer = cjson.decode(data) if not footer then - rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err) + rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err_message) else if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "") then + + -- NEW: Analyze structure BEFORE processing + local structure = analyze_mime_structure(task) + rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s, vars=%s", uname, footer.html, footer.plain, footer.vars) + rspamd_logger.infox(rspamd_config, "MIME structure analysis: depth=%d, has_related=%s, has_mixed=%s, has_inline=%s, has_attach=%s, complex=%s", + structure.depth, + tostring(structure.has_related), + tostring(structure.has_mixed), + tostring(structure.has_inline_images), + tostring(structure.has_attachments), + tostring(structure.is_complex)) + + -- NEW: Skip footer for ultra-complex structures (Outlook signature + attachments) + if structure.is_complex then + rspamd_logger.warnx(rspamd_config, "SKIPPING footer for complex MIME structure (likely Outlook signature + attachments) to prevent attachment loss for user %s", uname) + return + end if footer.skip_replies ~= 0 then in_reply_to = task:get_header_raw('in-reply-to') @@ -826,7 +597,6 @@ rspamd_config:register_symbol({ from_name = envfrom[1].name end - -- default replacements local replacements = { auth_user = uname, from_user = envfrom[1].user, @@ -834,16 +604,16 @@ rspamd_config:register_symbol({ from_addr = envfrom[1].addr, from_domain = envfrom[1].domain:lower() } - -- add custom mailbox attributes + if footer.vars and type(footer.vars) == "string" then local footer_vars = cjson.decode(footer.vars) - if type(footer_vars) == "table" then for key, value in pairs(footer_vars) do replacements[key] = value end end end + if footer.html and footer.html ~= "" then footer.html = lua_util.jinja_template(footer.html, replacements, true) end @@ -851,42 +621,68 @@ rspamd_config:register_symbol({ footer.plain = lua_util.jinja_template(footer.plain, replacements, true) end - -- add footer local out = {} local rewrite = lua_mime.add_text_footer(task, footer.html, footer.plain) or {} local seen_cte local newline_s = newline(task) + local ct = task:get_header('Content-Type', false) + local is_multipart = ct and ct:lower():match('^multipart/') + + local function has_non_ascii(text) + if not text or text == "" then return false end + return text:match('[\128-\255]') ~= nil + end + + local footer_needs_utf8 = has_non_ascii(footer.html) or has_non_ascii(footer.plain) + local function rewrite_ct_cb(name, hdr) if rewrite.need_rewrite_ct then if name:lower() == 'content-type' then - -- include boundary if present local boundary_part = rewrite.new_ct.boundary and string.format('; boundary="%s"', rewrite.new_ct.boundary) or '' - local nct = string.format('%s: %s/%s; charset=utf-8%s', - 'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype, boundary_part) + + local charset_part = '' + if not is_multipart then + if footer_needs_utf8 then + charset_part = '; charset=utf-8' + rspamd_logger.infox(rspamd_config, "footer contains non-ASCII characters, forcing UTF-8 charset") + else + local orig_charset = hdr.raw:match('[Cc][Hh][Aa][Rr][Ss][Ee][Tt]%s*=%s*"?([^;%s"]+)') + if orig_charset then + charset_part = string.format('; charset=%s', orig_charset) + else + charset_part = '; charset=utf-8' + end + end + end + + local nct = string.format('%s: %s/%s%s%s', + 'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype, charset_part, boundary_part) out[#out + 1] = nct - -- update Content-Type header (include boundary if present) task:set_milter_reply({ remove_headers = {['Content-Type'] = 0}, }) task:set_milter_reply({ - add_headers = {['Content-Type'] = string.format('%s/%s; charset=utf-8%s', - rewrite.new_ct.type, rewrite.new_ct.subtype, boundary_part)} + add_headers = {['Content-Type'] = string.format('%s/%s%s%s', + rewrite.new_ct.type, rewrite.new_ct.subtype, charset_part, boundary_part)} }) return elseif name:lower() == 'content-transfer-encoding' then - out[#out + 1] = string.format('%s: %s', - 'Content-Transfer-Encoding', 'quoted-printable') - -- update Content-Transfer-Encoding header - task:set_milter_reply({ - remove_headers = {['Content-Transfer-Encoding'] = 0}, - }) - task:set_milter_reply({ - add_headers = {['Content-Transfer-Encoding'] = 'quoted-printable'} - }) - seen_cte = true + if not is_multipart then + out[#out + 1] = string.format('%s: %s', + 'Content-Transfer-Encoding', 'quoted-printable') + task:set_milter_reply({ + remove_headers = {['Content-Transfer-Encoding'] = 0}, + }) + task:set_milter_reply({ + add_headers = {['Content-Transfer-Encoding'] = 'quoted-printable'} + }) + seen_cte = true + else + out[#out + 1] = hdr.raw:gsub('\r?\n?$', '') + end return end end @@ -895,11 +691,10 @@ rspamd_config:register_symbol({ task:headers_foreach(rewrite_ct_cb, {full = true}) - if not seen_cte and rewrite.need_rewrite_ct then + if not seen_cte and rewrite.need_rewrite_ct and not is_multipart then out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable') end - -- End of headers out[#out + 1] = newline_s if rewrite.out then @@ -909,17 +704,23 @@ rspamd_config:register_symbol({ else out[#out + 1] = task:get_rawbody() end + local out_parts = {} for _,o in ipairs(out) do if type(o) ~= 'table' then out_parts[#out_parts + 1] = o out_parts[#out_parts + 1] = newline_s else - local removePrefix = "--\x0D\x0AContent-Type" - if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then - o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1) + local part_content = tostring(o[1]) + if is_multipart then + out_parts[#out_parts + 1] = part_content + else + local removePrefix = "--\r\nContent-Type" + if string.lower(string.sub(part_content, 1, string.len(removePrefix))) == string.lower(removePrefix) then + part_content = string.sub(part_content, string.len("--\r\n") + 1) + end + out_parts[#out_parts + 1] = part_content end - out_parts[#out_parts + 1] = o[1] if o[2] then out_parts[#out_parts + 1] = newline_s end @@ -933,7 +734,6 @@ rspamd_config:register_symbol({ end end - -- fetch footer rspamd_http.request({ task=task, url='http://nginx:8081/footer.php', @@ -945,4 +745,4 @@ rspamd_config:register_symbol({ return true end, priority = 1 -}) \ No newline at end of file +})