Just getting the time to look at this again guys.
Umlaut/Special Character Corruption
When emails use non-UTF-8 character sets (like iso-8859-1, windows-1252 commonly used in German emails), the footer (which is in UTF-8) was being directly concatenated with the original content without charset conversion. This caused German umlauts (ä, ö, ü, ß) and other special characters to display as corrupted characters.
Original email: iso-8859-1 encoding with “Grüße” (greetings)
Footer: UTF-8 encoding with “Mit freundlichen Grüßen”
Result: Mixed encoding causing display as “Grüße” instead of “Grüße”
Forwarded Messages Losing Attachments
The boundary closure logic was too aggressive when handling complex nested MIME structures (like forwarded messages with embedded images). It would close boundaries prematurely, causing attachments to be lost.
I would advise you to make a backup of your rspamd.local.lua before applying this unoffical patch.
Backup current file
cp /opt/mailcow-dockerized/data/conf/rspamd/rspamd.local.lua \
/opt/mailcow-dockerized/data/conf/rspamd/rspamd.local.lua.backup
Deploy new version
cp rspamd.local.lua /opt/mailcow-dockerized/data/conf/rspamd/
Restart rspamd
cd /opt/mailcow-dockerized && docker-compose restart rspamd-mailcow
-- PATCHED lua_mime.add_text_footer that fixes boundary ordering with attachments
-- Universal compatibility for ALL email clients including legacy Outlook 2013/2016
-- CRITICAL FIX: Inserts HTML footers BEFORE </body> or </html>, not after
-- CHARSET FIX: Properly converts footer to match original content encoding to prevent corruption
-- FORWARD FIX: Properly handles forwarded messages with complex MIME structures
local function newline(task)
local t = task:get_newlines_type()
if t == 'cr' then
return '\r'
elseif t == 'lf' then
return '\n'
end
return '\r\n'
end
-- Helper function to extract charset from Content-Type header
local function get_charset_from_header(header_value)
if not header_value then
return nil
end
local charset_match = header_value:match('charset=([^;%s]+)')
if charset_match then
return charset_match:gsub('"', ''):lower()
end
return nil
end
-- Helper function to convert text from UTF-8 to target charset
local function convert_charset(text, target_charset)
local rspamd_util = require "rspamd_util"
-- Normalize charset names
target_charset = target_charset:lower():gsub('-', '')
-- If target is UTF-8, no conversion needed
if target_charset == 'utf8' or target_charset == 'utf8' then
return text
end
-- Common charset mappings for iconv
local charset_map = {
['iso88591'] = 'ISO-8859-1',
['iso88592'] = 'ISO-8859-2',
['iso885915'] = 'ISO-8859-15',
['windows1252'] = 'WINDOWS-1252',
['cp1252'] = 'WINDOWS-1252',
['latin1'] = 'ISO-8859-1',
}
local iconv_charset = charset_map[target_charset] or target_charset:upper()
-- Try to convert using rspamd's charset conversion
local success, result = pcall(function()
return rspamd_util.iconv(text, 'UTF-8', iconv_charset)
end)
if success and result then
return result
else
-- Fallback: return original text if conversion fails
-- This prevents crashes but may still show corrupted characters
return text
end
end
local function do_append_footer(task, part, footer, is_multipart, out, state)
local rspamd_util = require "rspamd_util"
local rspamd_text = require "rspamd_text"
local tp = part:get_text()
local ct = 'text/plain'
local cte = 'quoted-printable'
local newline_s = state.newline_s
if tp:is_html() then
ct = 'text/html'
end
-- Get the original charset from the part header
local part_ct_header = part:get_header('Content-Type')
local original_charset = get_charset_from_header(part_ct_header) or 'utf-8'
local original_cte = part:get_cte() or '7bit'
-- CRITICAL FIX: Convert footer to match original charset
local converted_footer = footer
if original_charset ~= 'utf-8' and original_charset ~= 'utf8' then
converted_footer = convert_charset(footer, original_charset)
end
local encode_func = function(input)
return rspamd_util.encode_qp(input, 80, task:get_newlines_type())
end
-- Determine if we need to change encoding
local use_original_encoding = false
if original_cte == '7bit' or original_cte == '8bit' then
-- Check if content + footer is pure ASCII
local content_check = tp:get_content('raw_utf') or ''
local is_pure_ascii = true
-- Check content
for i = 1, #content_check do
if string.byte(content_check, i) > 127 then
is_pure_ascii = false
break
end
end
-- Check converted footer
if is_pure_ascii then
for i = 1, #converted_footer do
if string.byte(converted_footer, i) > 127 then
is_pure_ascii = false
break
end
end
end
-- Only use original encoding if everything is pure ASCII
if is_pure_ascii then
use_original_encoding = true
cte = original_cte
encode_func = function(input)
if type(input) == 'userdata' then
return input
else
return rspamd_text.fromstring(input)
end
end
end
end
if is_multipart then
out[#out + 1] = string.format('Content-Type: %s; charset=%s%s' ..
'Content-Transfer-Encoding: %s',
ct, original_charset, newline_s, cte)
out[#out + 1] = ''
else
state.new_cte = cte
end
-- Store charset for later use
state.output_charset = original_charset
-- Get content in the original charset
local content
if original_cte == 'quoted-printable' then
content = rspamd_util.decode_qp(tp:get_content('raw_parsed'))
elseif original_cte == 'base64' then
content = rspamd_util.decode_base64(tp:get_content('raw_parsed'))
else
-- For non-encoded content, get raw to preserve charset
content = tp:get_content('raw_parsed') or ''
end
-- CRITICAL FIX: For HTML, insert footer BEFORE </body> or </html>, not after
if tp:is_html() then
-- Extract body content from footer if it's a complete HTML document
local footer_content = converted_footer
local footer_lower = converted_footer:lower()
local footer_body_start = footer_lower:find('<body[^>]*>')
local footer_body_end = footer_lower:find('</body%s*>')
if footer_body_start and footer_body_end then
-- Footer is a complete HTML doc - extract just the body content
local body_tag_end = converted_footer:find('>', footer_body_start, true)
if body_tag_end then
footer_content = converted_footer:sub(body_tag_end + 1, footer_body_end - 1)
end
end
local lower = content:lower()
local insert_pos = nil
-- Try to find </body> first (best position)
local body_end = lower:find('</body%s*>')
if body_end then
insert_pos = body_end
else
-- Fallback: try </html>
local html_end = lower:find('</html%s*>')
if html_end then
insert_pos = html_end
end
end
if insert_pos then
-- Insert footer BEFORE the closing tag
content = content:sub(1, insert_pos - 1) .. footer_content .. content:sub(insert_pos)
else
-- No closing tags found, append to end (fallback)
content = content .. footer_content
end
else
-- Plain text: just append
local double_nline = newline_s .. newline_s
local nlen = #double_nline
if content:sub(-(nlen), nlen + 1) == double_nline then
content = content:sub(0, -(#newline_s + 1)) .. converted_footer
else
content = content .. converted_footer
end
end
out[#out + 1] = { encode_func(content), true }
out[#out + 1] = ''
end
local function add_text_footer_fixed(task, html_footer, text_footer)
local rspamd_util = require "rspamd_util"
local newline_s = newline(task)
local state = {
newline_s = newline_s
}
local out = {}
local text_parts = task:get_text_parts()
if not (html_footer or text_footer) or not (text_parts and #text_parts > 0) then
return false
end
if html_footer or text_footer then
local ct = task:get_header('Content-Type')
if ct then
ct = rspamd_util.parse_content_type(ct, task:get_mempool())
end
if ct then
if ct.type and ct.type == 'text' then
if ct.subtype then
if html_footer and (ct.subtype == 'html' or ct.subtype == 'htm') then
state.need_rewrite_ct = true
elseif text_footer and ct.subtype == 'plain' then
state.need_rewrite_ct = true
end
else
if text_footer then
state.need_rewrite_ct = true
end
end
state.new_ct = ct
end
else
if text_parts then
if #text_parts == 1 then
state.need_rewrite_ct = true
state.new_ct = {
type = 'text',
subtype = 'plain'
}
elseif #text_parts > 1 then
state.new_ct = {
type = 'multipart',
subtype = 'mixed'
}
end
end
end
end
local boundaries = {}
local cur_boundary
local parts = task:get_parts()
for i, part in ipairs(parts) do
local boundary = part:get_boundary()
local part_ct = part:get_header('Content-Type')
if part_ct then
part_ct = rspamd_util.parse_content_type(part_ct, task:get_mempool())
end
if part:is_multipart() then
if cur_boundary then
out[#out + 1] = string.format('--%s', boundaries[#boundaries].boundary)
end
boundaries[#boundaries + 1] = {
boundary = boundary or '--XXX',
ct_type = part_ct and part_ct.type or '',
ct_subtype = part_ct and part_ct.subtype or '',
}
cur_boundary = boundary
local rh = part:get_raw_headers()
if #rh > 0 then
out[#out + 1] = { rh, true }
end
elseif part:is_message() then
if boundary then
if cur_boundary and boundary ~= cur_boundary then
out[#out + 1] = string.format('--%s--%s', boundaries[#boundaries].boundary, newline_s)
table.remove(boundaries)
cur_boundary = nil
end
out[#out + 1] = string.format('--%s', boundary)
end
out[#out + 1] = { part:get_raw_headers(), true }
else
local append_footer = false
local skip_footer = part:is_attachment()
local parent = part:get_parent()
if parent then
local t, st = parent:get_type()
if t == 'multipart' and st == 'signed' then
skip_footer = true
end
end
if text_footer and part:is_text() then
local tp = part:get_text()
if not tp:is_html() then
append_footer = text_footer
end
end
if html_footer and part:is_text() then
local tp = part:get_text()
if tp:is_html() then
append_footer = html_footer
end
end
if boundary then
if cur_boundary and boundary ~= cur_boundary then
-- FORWARD FIX: Better handling of nested boundaries
-- Don't close boundaries prematurely when there are more parts
local should_close = false
local has_more_parts = false
-- Check if there are more non-multipart parts coming
for j = i + 1, #parts do
if not parts[j]:is_multipart() and not parts[j]:is_message() then
has_more_parts = true
break
end
end
-- Only close if we're at the end or transitioning to a different multipart section
if not has_more_parts then
should_close = true
end
if should_close and #boundaries > 0 then
-- Close the current boundary with proper formatting
out[#out + 1] = string.format('--%s--%s', boundaries[#boundaries].boundary, newline_s)
table.remove(boundaries)
end
cur_boundary = boundary
end
-- Ensure boundary has no trailing whitespace for Outlook
out[#out + 1] = string.format('--%s', boundary)
end
if append_footer and not skip_footer then
do_append_footer(task, part, append_footer, parent and parent:is_multipart(), out, state)
else
out[#out + 1] = { part:get_raw_headers(), true }
out[#out + 1] = { part:get_raw_content(), false }
end
end
end
-- Close any remaining boundaries
local b = table.remove(boundaries)
while b do
out[#out + 1] = string.format('--%s--', b.boundary)
if #boundaries > 0 then
out[#out + 1] = ''
end
b = table.remove(boundaries)
end
state.out = out
return state
end
local lua_mime = require "lua_mime"
lua_mime.add_text_footer = add_text_footer_fixed
rspamd_config.MAILCOW_AUTH = {
callback = function(task)
local uname = task:get_user()
if uname then
return 1
end
end
}
local monitoring_hosts = rspamd_config:add_map{
url = "/etc/rspamd/custom/monitoring_nolog.map",
description = "Monitoring hosts",
type = "regexp"
}
rspamd_config:register_symbol({
name = 'SMTP_ACCESS',
type = 'postfilter',
callback = function(task)
local util = require("rspamd_util")
local rspamd_logger = require "rspamd_logger"
local rspamd_ip = require 'rspamd_ip'
local uname = task:get_user()
local limited_access = task:get_symbol("SMTP_LIMITED_ACCESS")
if not uname then
return false
end
if not limited_access then
return false
end
local hash_key = 'SMTP_ALLOW_NETS_' .. uname
local redis_params = rspamd_parse_redis_server('smtp_access')
local ip = task:get_from_ip()
if ip == nil or not ip:is_valid() then
return false
end
local from_ip_string = tostring(ip)
smtp_access_table = {from_ip_string}
local maxbits = 128
local minbits = 32
if ip:get_version() == 4 then
maxbits = 32
minbits = 8
end
for i=maxbits,minbits,-1 do
local nip = ip:apply_mask(i):to_string() .. "/" .. i
table.insert(smtp_access_table, nip)
end
local function smtp_access_cb(err, data)
if err then
rspamd_logger.infox(rspamd_config, "smtp_access query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
return false
else
rspamd_logger.infox(rspamd_config, "checking ip %s for smtp_access in %s", from_ip_string, hash_key)
for k,v in pairs(data) do
if (v and v ~= userdata and v == '1') then
rspamd_logger.infox(rspamd_config, "found ip in smtp_access map")
task:insert_result(true, 'SMTP_ACCESS', 0.0, from_ip_string)
return true
end
end
rspamd_logger.infox(rspamd_config, "couldnt find ip in smtp_access map")
task:insert_result(true, 'SMTP_ACCESS', 999.0, from_ip_string)
return true
end
end
table.insert(smtp_access_table, 1, hash_key)
local redis_ret_user = rspamd_redis_make_request(task,
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")
end
end,
priority = 10
})
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)
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
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,
priority = 10
})
rspamd_config:register_symbol({
name = 'KEEP_SPAM',
type = 'prefilter',
callback = function(task)
local util = require("rspamd_util")
local rspamd_logger = require "rspamd_logger"
local rspamd_ip = require 'rspamd_ip'
local uname = task:get_user()
if uname then
return false
end
local redis_params = rspamd_parse_redis_server('keep_spam')
local ip = task:get_from_ip()
if ip == nil or not ip:is_valid() then
return false
end
local from_ip_string = tostring(ip)
ip_check_table = {from_ip_string}
local maxbits = 128
local minbits = 32
if ip:get_version() == 4 then
maxbits = 32
minbits = 8
end
for i=maxbits,minbits,-1 do
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)
return false
else
for k,v in pairs(data) do
if (v and v ~= userdata and v == '1') then
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')
end
end
end
end
table.insert(ip_check_table, 1, 'KEEP_SPAM')
local redis_ret_user = rspamd_redis_make_request(task,
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")
end
end,
priority = 19
})
rspamd_config:register_symbol({
name = 'TLS_HEADER',
type = 'postfilter',
callback = function(task)
local rspamd_logger = require "rspamd_logger"
local tls_tag = task:get_request_header('TLS-Version')
if type(tls_tag) == 'nil' then
task:set_milter_reply({
add_headers = {['X-Last-TLS-Session-Version'] = 'None'}
})
else
task:set_milter_reply({
add_headers = {['X-Last-TLS-Session-Version'] = tostring(tls_tag)}
})
end
end,
priority = 12
})
rspamd_config:register_symbol({
name = 'TAG_MOO',
type = 'postfilter',
callback = function(task)
local util = require("rspamd_util")
local rspamd_logger = require "rspamd_logger"
local redis_params = rspamd_parse_redis_server('taghandler')
local rspamd_http = require "rspamd_http"
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
task:set_milter_reply({
remove_headers = {['X-Moo-Tag'] = 0},
})
end
return true
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)
if action ~= 'no action' and action ~= 'greylist' then
rspamd_logger.infox("skipping tag handler for action: %s", action)
remove_moo_tag()
return true
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)
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 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
end
end
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
end
end
end
else
remove_moo_tag()
end
end,
priority = 19
})
rspamd_config:register_symbol({
name = 'BCC',
type = 'postfilter',
callback = function(task)
local util = require("rspamd_util")
local rspamd_http = require "rspamd_http"
local rspamd_logger = require "rspamd_logger"
local from_table = {}
local rcpt_table = {}
if task:has_symbol('ENCRYPTED_CHAT') then
return
end
local send_mail = function(task, bcc_dest)
local lua_smtp = require "lua_smtp"
local function sendmail_cb(ret, err)
if not ret then
rspamd_logger.errx(task, 'BCC SMTP ERROR: %s', err)
else
rspamd_logger.infox(rspamd_config, "BCC SMTP SUCCESS TO %s", bcc_dest)
end
end
if not bcc_dest then
return
end
local email_content = tostring(task:get_content())
email_content = string.gsub(email_content, "\r\n%.", "\r\n..")
lua_smtp.sendmail({
task = task,
host = os.getenv("IPV4_NETWORK") .. '.253',
port = 591,
from = task:get_from(stp)[1].addr,
recipients = bcc_dest,
helo = 'bcc',
timeout = 20,
}, email_content, sendmail_cb)
end
local from = task:get_from('smtp')
if from then
for _, a in ipairs(from) do
table.insert(from_table, a['addr'])
table.insert(from_table, '@' .. a['domain'])
end
else
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
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
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, "checking bcc for rcpt address %s", e)
rspamd_http.request({
task=task,
url='http://nginx:8081/bcc.php',
body='',
callback=rcpt_callback,
headers={Rcpt=e}
})
end
end
if from_table then
for _,e in ipairs(from_table) do
rspamd_logger.infox(rspamd_config, "checking bcc for from address %s", e)
rspamd_http.request({
task=task,
url='http://nginx:8081/bcc.php',
body='',
callback=from_callback,
headers={From=e}
})
end
end
return true
end,
priority = 20
})
rspamd_config:register_symbol({
name = 'DYN_RL_CHECK',
type = 'prefilter',
callback = function(task)
local util = require("rspamd_util")
local redis_params = rspamd_parse_redis_server('dyn_rl')
local rspamd_logger = require "rspamd_logger"
local envfrom = task:get_from(1)
local envrcpt = task:get_recipients(1) or {}
local uname = task:get_user()
if not envfrom or not uname then
return false
end
local uname = uname:lower()
if #envrcpt == 1 and envrcpt[1].addr:lower() == uname then
return false
end
local env_from_domain = envfrom[1].domain:lower()
local function redis_cb_user(err, data)
if err or type(data) ~= 'string' then
rspamd_logger.infox(rspamd_config, "dynamic ratelimit request for user %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying dynamic ratelimit for domain...", uname, data, err)
local function redis_key_cb_domain(err, data)
if err or type(data) ~= 'string' then
rspamd_logger.infox(rspamd_config, "dynamic ratelimit request for domain %s returned invalid or empty data (\"%s\") or error (\"%s\")", env_from_domain, data, err)
else
rspamd_logger.infox(rspamd_config, "found dynamic ratelimit in redis for domain %s with value %s", env_from_domain, data)
task:insert_result('DYN_RL', 0.0, data, env_from_domain)
end
end
local redis_ret_domain = rspamd_redis_make_request(task,
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")
end
else
rspamd_logger.infox(rspamd_config, "found dynamic ratelimit in redis for user %s with value %s", uname, data)
task:insert_result('DYN_RL', 0.0, data, uname)
end
end
local redis_ret_user = rspamd_redis_make_request(task,
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")
end
return true
end,
flags = 'empty',
priority = 20
})
rspamd_config:register_symbol({
name = 'NO_LOG_STAT',
type = 'postfilter',
callback = function(task)
local from = task:get_header('From')
if from and (monitoring_hosts:get_key(from) or from == "watchdog@localhost") then
task:set_flag('no_log')
task:set_flag('no_stat')
end
end
})
rspamd_config:register_symbol({
name = 'MOO_FOOTER',
type = 'prefilter',
callback = function(task)
local cjson = require "cjson"
local lua_mime = require "lua_mime"
local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger"
local rspamd_http = require "rspamd_http"
local envfrom = task:get_from(1)
local uname = task:get_user()
if not envfrom or not uname then
return false
end
local uname = uname:lower()
local env_from_domain = envfrom[1].domain:lower()
local env_from_addr = envfrom[1].addr:lower()
local function newline(task)
local t = task:get_newlines_type()
if t == 'cr' then
return '\r'
elseif t == 'lf' then
return '\n'
end
return '\r\n'
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_message)
else
if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "") then
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)
if footer.skip_replies ~= 0 then
in_reply_to = task:get_header_raw('in-reply-to')
if in_reply_to then
rspamd_logger.infox(rspamd_config, "mail is a reply - skip footer")
return
end
end
local envfrom_mime = task:get_from(2)
local from_name = ""
if envfrom_mime and envfrom_mime[1].name then
from_name = envfrom_mime[1].name
elseif envfrom and envfrom[1].name then
from_name = envfrom[1].name
end
local replacements = {
auth_user = uname,
from_user = envfrom[1].user,
from_name = from_name,
from_addr = envfrom[1].addr,
from_domain = envfrom[1].domain:lower()
}
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
if footer.plain and footer.plain ~= "" then
footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
end
local out = {}
local rewrite = lua_mime.add_text_footer(task, footer.html, footer.plain) or {}
local seen_cte
local newline_s = newline(task)
local function rewrite_ct_cb(name, hdr)
if rewrite.need_rewrite_ct then
if name:lower() == 'content-type' then
local boundary_part = rewrite.new_ct.boundary and
string.format('; boundary="%s"', rewrite.new_ct.boundary) or ''
-- Use charset from the rewrite state (preserves original)
local charset = rewrite.output_charset or 'utf-8'
local nct = string.format('%s: %s/%s; charset=%s%s',
'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype, charset, boundary_part)
out[#out + 1] = nct
task:set_milter_reply({
remove_headers = {['Content-Type'] = 0},
})
task:set_milter_reply({
add_headers = {['Content-Type'] = string.format('%s/%s; charset=%s%s',
rewrite.new_ct.type, rewrite.new_ct.subtype, charset, boundary_part)}
})
return
elseif name:lower() == 'content-transfer-encoding' 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
return
end
end
out[#out + 1] = hdr.raw:gsub('\r?\n?$', '')
end
task:headers_foreach(rewrite_ct_cb, {full = true})
if not seen_cte and rewrite.need_rewrite_ct then
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable')
end
out[#out + 1] = newline_s
if rewrite.out then
for _,o in ipairs(rewrite.out) do
out[#out + 1] = o
end
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)
end
out_parts[#out_parts + 1] = o[1]
if o[2] then
out_parts[#out_parts + 1] = newline_s
end
end
end
task:set_message(out_parts)
else
rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\")", uname, data)
end
end
end
end
rspamd_http.request({
task=task,
url='http://nginx:8081/footer.php',
body='',
callback=footer_cb,
headers={Domain=env_from_domain,Username=uname,From=env_from_addr},
})
return true
end,
priority = 1
})