function1983 @CK_beats
Outlook creates an absolute mess when you have a custom signature with an image plus file attachments. It nests everything in this complex multipart structure - you’ve got multipart/related for the signature image, multipart/mixed for the attachments, and they’re all nested inside each other. When our footer code tried to insert itself into this structure, it was basically breaking the boundaries that tell email clients where one part ends and another begins. Result: attachments just disappear.
What I Did
I added some detection logic that analyzes the email structure before trying to add the footer. It walks through all the MIME parts and checks:
How many layers deep is this thing nested?
Is there a multipart/related section? (Outlook signatures)
Is there a multipart/mixed section? (attachments)
Are there both inline images AND separate file attachments?
If it sees that specific combination - the Outlook signature mess plus attachments - it just skips adding the footer entirely. Better to not have a footer than lose someone’s files.
I also threw in some extra logging so we can see in the rspamd logs what it’s detecting. Makes troubleshooting easier if issues come up.
The Trade-off
Users with Outlook signatures won’t get Domain Wide footers on their emails anymore. But they’ll actually receive all their attachments, which seems like the more important problem to solve.
The Gmail filename issue with umlauts (where dührkop.pdf turns into noname.pdf) is still there - that’s a different encoding problem and I didn’t want to change too much at once.
Testing
Deploy it, check the logs for “SKIPPING footer” messages on those problematic Outlook emails, and see if the attachment complaints stop. Everything that was working before (simple emails, German umlauts, regular forwards) should keep working exactly the same.
I am beginning to think there is not a one size fits all approach where the Domain Wide Footer is concerned. If you prefer to just use inline signatures then turn off Domain Wide Footer for them email accounts, or remove inline signatures to use just DWF. Having both seems like overkill.
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
-- 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, multipart_types = {}}
end
local max_depth = 0
local multipart_types = {}
local has_related = false
local has_mixed = false
local has_inline_images = false
local has_attachments = false
local function traverse(part, depth)
if depth > max_depth then max_depth = depth end
if part:is_multipart() then
local mtype, msubtype = part:get_type()
if mtype == 'multipart' then
table.insert(multipart_types, msubtype)
if msubtype == 'related' then has_related = true end
if msubtype == 'mixed' then has_mixed = true end
end
local children = part:get_parts()
if children then
for _, child in ipairs(children) do
traverse(child, depth + 1)
end
end
elseif part:is_attachment() then
has_attachments = true
elseif 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
for _, part in ipairs(parts) do
traverse(part, 0)
end
-- Complex structure = Outlook signature scenario
local is_complex = (has_related and has_mixed and has_inline_images and has_attachments) or max_depth > 2
return {
simple = max_depth <= 1,
depth = max_depth,
has_related = has_related,
has_mixed = has_mixed,
has_inline_images = has_inline_images,
has_attachments = has_attachments,
multipart_types = multipart_types,
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_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')
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 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
local boundary_part = rewrite.new_ct.boundary and
string.format('; boundary="%s"', rewrite.new_ct.boundary) or ''
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
task:set_milter_reply({
remove_headers = {['Content-Type'] = 0},
})
task:set_milter_reply({
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
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
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 and not is_multipart 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 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
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
})
@[deleted]
Gmail Filename Encoding Issue
The problem is Gmail doesn’t understand non-ASCII characters in the basic filename=“dührkop.pdf” format. It needs RFC 2231 encoding which looks like: filename*=UTF-8′'d%C3%BChrkop.pdf
The Challenge
We need to re-encode attachment filenames, but there’s a catch - rspamd’s Lua API might not let us modify MIME part headers after they’ve been processed by the built-in footer function.
What We Need to Try
Add this function somewhere near the top with the other helper functions (around line 15-20):
-- Encode filename for RFC 2231 (fixes Gmail umlaut issue)
local function encode_filename_rfc2231(filename)
if not filename or filename == "" then return filename end
-- Check if already has non-ASCII
if not filename:match('[\128-\255]') then return filename end
-- Percent-encode non-ASCII bytes
local encoded = ""
for i = 1, #filename do
local byte = string.byte(filename, i)
if byte > 127 or byte == 37 or byte == 39 or byte == 42 then
encoded = encoded .. string.format("%%%02X", byte)
else
encoded = encoded .. string.char(byte)
end
end
return string.format("UTF-8''%s", encoded)
end
Then add this code after the footer is applied but before task:set_message(out_parts) - around line 790:
-- FIX: Re-encode attachment filenames with umlauts for Gmail
local parts = task:get_parts()
for _, part in ipairs(parts) do
if part:is_attachment() then
local cd = part:get_header('Content-Disposition')
if cd then
local filename = cd:match('filename="?([^";]+)"?')
if filename and filename:match('[\128-\255]') then
local encoded = encode_filename_rfc2231(filename)
local new_cd = cd:gsub('filename="?[^";]+"?', 'filename*=' .. encoded)
-- Try to update header (might not work)
pcall(function()
part:set_header('Content-Disposition', new_cd)
end)
rspamd_logger.infox(rspamd_config, "Re-encoded filename: %s -> %s", filename, encoded)
end
end
end
end
The Problem
I’m not confident part:set_header() actually exists or works in rspamd. This might just silently fail.
Alternative Approach (If Above Fails)
We’d need to modify the MIME reconstruction in the out_parts loop to manually rewrite Content-Disposition headers, which is way more invasive and risky.
My honest take. Try the code above first. If logs show “Re-encoded filename” but Gmail still shows “noname”, then rspamd doesn’t let us modify headers after the fact and we’d need a completely different approach, possibly patching at the postfix level instead of rspamd.
@[deleted]
@[deleted]
Gmail Filename Encoding Issue
The problem is Gmail doesn’t understand non-ASCII characters in the basic filename=“dührkop.pdf” format. It needs RFC 2231 encoding which looks like: filename*=UTF-8′'d%C3%BChrkop.pdf
The Challenge
We need to re-encode attachment filenames, but there’s a catch - rspamd’s Lua API might not let us modify MIME part headers after they’ve been processed by the built-in footer function.
What We Need to Try
Add this function somewhere near the top with the other helper functions (around line 15-20):
-- Encode filename for RFC 2231 (fixes Gmail umlaut issue)
local function encode_filename_rfc2231(filename)
if not filename or filename == "" then return filename end
-- Check if already has non-ASCII
if not filename:match('[\128-\255]') then return filename end
-- Percent-encode non-ASCII bytes
local encoded = ""
for i = 1, #filename do
local byte = string.byte(filename, i)
if byte > 127 or byte == 37 or byte == 39 or byte == 42 then
encoded = encoded .. string.format("%%%02X", byte)
else
encoded = encoded .. string.char(byte)
end
end
return string.format("UTF-8''%s", encoded)
end
Then add this code after the footer is applied but before task:set_message(out_parts) - around line 790:
-- FIX: Re-encode attachment filenames with umlauts for Gmail
local parts = task:get_parts()
for _, part in ipairs(parts) do
if part:is_attachment() then
local cd = part:get_header('Content-Disposition')
if cd then
local filename = cd:match('filename="?([^";]+)"?')
if filename and filename:match('[\128-\255]') then
local encoded = encode_filename_rfc2231(filename)
local new_cd = cd:gsub('filename="?[^";]+"?', 'filename*=' .. encoded)
-- Try to update header (might not work)
pcall(function()
part:set_header('Content-Disposition', new_cd)
end)
rspamd_logger.infox(rspamd_config, "Re-encoded filename: %s -> %s", filename, encoded)
end
end
end
end
The Problem
I’m not confident part:set_header() actually exists or works in rspamd. This might just silently fail.
Alternative Approach (If Above Fails)
We’d need to modify the MIME reconstruction in the out_parts loop to manually rewrite Content-Disposition headers, which is way more invasive and risky.
My honest take. Try the code above first. If logs show “Re-encoded filename” but Gmail still shows “noname”, then rspamd doesn’t let us modify headers after the fact and we’d need a completely different approach, possibly patching at the postfix level instead of rspamd.