File: //proc/thread-self/root/usr/share/rspamd/lualib/lua_feedback_parsers.lua
--[[
Copyright (c) 2026, Vsevolod Stakhov <vsevolod@rspamd.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
]] --
--[[[
-- @module lua_feedback_parsers
-- This module provides parsers for inbound feedback reports that arrive as
-- a regular message (MIME) on a task:
--
-- * RFC 3464 Delivery Status Notifications (DSN / bounces)
-- * RFC 5965 Abuse Reporting Format (ARF / FBL)
--
-- The parsers operate on a `task` object and return a structured Lua table
-- describing the report, or `nil` if the message is not a report of that
-- kind. Both parsers are defensive: malformed bodies will not raise an
-- error, they will produce a partial result at best (documented per
-- function) or `nil`.
--]]
local rspamd_logger = require 'rspamd_logger'
local lua_util = require 'lua_util'
local N = 'lua_feedback_parsers'
local str_trim = lua_util.str_trim
local str_split = lua_util.rspamd_str_split
local exports = {}
-- Strip a single pair of outermost angle brackets, e.g. `<id@example>`.
local function strip_angles(s)
if not s then
return nil
end
local inner = s:match('^%s*<(.-)>%s*$')
if inner then
return inner
end
return str_trim(s)
end
--[[
-- Parse an RFC 822 field block (a sequence of header-like lines terminated
-- by a blank line or end of input). Handles header folding: lines that
-- start with a tab or a space are continuations of the previous field.
--
-- Returns:
-- fields - map of lowercased field name -> value (trimmed string)
-- fields_multi - map of lowercased field name -> array of values (in order
-- of appearance); useful for repeated fields such as `Reported-URI`.
-- next_line - 1-based index of the first line AFTER the blank line that
-- terminated the block (or #lines + 1 if the block ran to end of input)
--
-- `start_line` is 1-based.
]]
local function parse_field_block(lines, start_line)
local fields = {}
local fields_multi = {}
local current_name
local current_value_parts
local i = start_line or 1
local n = #lines
local function flush()
if current_name then
local value = str_trim(table.concat(current_value_parts, ' '))
fields[current_name] = value
local list = fields_multi[current_name]
if not list then
list = {}
fields_multi[current_name] = list
end
list[#list + 1] = value
end
current_name = nil
current_value_parts = nil
end
while i <= n do
local line = lines[i]
if line == '' then
flush()
return fields, fields_multi, i + 1
end
local first = line:sub(1, 1)
if first == ' ' or first == '\t' then
if current_name then
current_value_parts[#current_value_parts + 1] = str_trim(line)
end
-- else: continuation with no preceding field - ignore
else
local name, value = line:match('^([^:]+):%s?(.*)$')
if name then
flush()
current_name = name:lower():gsub('%s+$', '')
current_value_parts = { value or '' }
end
-- else: malformed line - skip it
end
i = i + 1
end
flush()
return fields, fields_multi, i
end
-- Split an entire body into an array of field blocks separated by blank
-- lines. Used for message/delivery-status bodies which consist of 1..N
-- blocks.
local function parse_field_blocks(body)
if type(body) ~= 'string' then
body = tostring(body or '')
end
-- Normalise line endings (drop CR) then split on LF.
local lines = str_split(body:gsub('\r', ''), '\n')
if not lines then
return {}
end
local i = 1
while i <= #lines and lines[i] == '' do
i = i + 1
end
local blocks = {}
while i <= #lines do
local fields, fields_multi, next_i = parse_field_block(lines, i)
if next(fields) ~= nil then
blocks[#blocks + 1] = {
fields = fields,
fields_multi = fields_multi,
}
end
if next_i <= i then
break
end
i = next_i
while i <= #lines and lines[i] == '' do
i = i + 1
end
end
return blocks
end
-- Find the topmost multipart/report part in a task that matches the given
-- `report-type` (case-insensitive). Returns the matching mime_part or nil.
local function find_multipart_report(task, wanted_report_type)
for _, part in ipairs(task:get_parts() or {}) do
local t, st, params = part:get_type_full()
if t == 'multipart' and st == 'report' and type(params) == 'table' then
local rt = params['report-type']
if rt and rt:lower() == wanted_report_type then
return part
end
end
end
return nil
end
-- Find the first sub-part whose Content-Type matches `wanted_type/wanted_subtype`
-- (case-insensitive). If `wanted_subtype` is nil, only `wanted_type` is
-- matched.
local function find_part_by_type(task, wanted_type, wanted_subtype)
for _, part in ipairs(task:get_parts() or {}) do
local t, st = part:get_type()
if t and t:lower() == wanted_type and
(not wanted_subtype or (st and st:lower() == wanted_subtype)) then
return part
end
end
return nil
end
-- Locate the embedded original message in a report.
-- Returns (part, kind) where kind is 'full' for message/rfc822|message/global
-- (headers+body) and 'headers' for text/rfc822-headers (headers only).
local function find_original_message_part(task)
for _, part in ipairs(task:get_parts() or {}) do
local t, st = part:get_type()
if t and st then
local lt = t:lower()
local lst = st:lower()
if lt == 'message' and (lst == 'rfc822' or lst == 'global') then
return part, 'full'
end
if lt == 'text' and lst == 'rfc822-headers' then
return part, 'headers'
end
end
end
return nil
end
-- Extract the standard subset of original-message headers we care about from
-- the content of a message/rfc822 (or text/rfc822-headers) sub-part.
--
-- Returns (out, headers, headers_multi) where `out` is the parsed subset and
-- `headers`/`headers_multi` are the raw single/multi field maps. Callers may
-- use the raw maps to enrich a sparse report (see the JMRP handling in
-- parse_arf) without those raw maps leaking into the returned structure.
local function extract_original_message(part)
local content = part:get_content()
if not content then
return nil
end
content = tostring(content)
if content == '' then
return nil
end
local lines = str_split(content:gsub('\r', ''), '\n')
if not lines then
return nil
end
local headers, headers_multi = parse_field_block(lines, 1)
if not headers or next(headers) == nil then
return nil
end
local out = {
message_id = strip_angles(headers['message-id']),
from = strip_angles(headers['from']),
to = strip_angles(headers['to']),
subject = headers['subject'],
date = headers['date'],
}
if not (out.message_id or out.from or out.to or out.subject or out.date) then
return nil
end
return out, headers, headers_multi
end
-- ----------------------------------------------------------------------------
-- Enrichment helpers for sparse feedback reports
--
-- Some providers (most notably Microsoft's JMRP, but also a few consumer
-- FBLs) ship a `message/feedback-report` block that only carries
-- `Feedback-Type`, `User-Agent` and `Version`, omitting the useful metadata
-- (Source-IP, Arrival-Date, Original-Mail-From, Original-Rcpt-To). All of
-- that data still lives in the embedded original-message headers, so we
-- recover it from there when the report itself is silent.
-- ----------------------------------------------------------------------------
-- Validate a candidate IP string, tolerating surrounding [], (), an `IPv6:`
-- tag and a trailing `:port` on IPv4. Returns (canonical_string, ip_object)
-- or nil.
local function validate_ip(candidate)
if not candidate then
return nil
end
local rspamd_ip = require 'rspamd_ip'
local s = str_trim(candidate)
s = s:gsub('^[%[%(]', ''):gsub('[%]%)]$', '')
s = s:gsub('^[Ii][Pp][Vv]6:', '')
-- Strip a trailing :port for a bare IPv4 literal (a.b.c.d:NNN)
local v4 = s:match('^(%d+%.%d+%.%d+%.%d+)')
if v4 then
s = v4
end
local ip = rspamd_ip.from_string(s)
if ip and ip:is_valid() then
return ip:to_string(), ip
end
return nil
end
-- Classify an IP as non-routable (loopback, RFC1918/ULA private, CGNAT or
-- link-local). `ip:is_local()` only covers loopback and IPv6 link/site-local,
-- so we extend it to the private ranges that show up as internal Received
-- hops. Used to prefer a public sender IP when walking a Received chain.
local function is_nonpublic_ip(ipobj)
if ipobj:is_local() then
return true
end
local ver = ipobj:get_version()
if ver == 4 then
local o1, o2 = ipobj:to_string():match('^(%d+)%.(%d+)%.')
o1, o2 = tonumber(o1), tonumber(o2)
if not o1 then
return false
end
if o1 == 10 or o1 == 127 or o1 == 0 then
return true
end
if o1 == 192 and o2 == 168 then
return true
end
if o1 == 172 and o2 >= 16 and o2 <= 31 then
return true
end
if o1 == 169 and o2 == 254 then
return true
end
if o1 == 100 and o2 >= 64 and o2 <= 127 then
return true
end
return false
elseif ver == 6 then
local s = ipobj:to_string():lower()
-- fc00::/7 (ULA) or fe80::/10 (link-local)
if s:match('^f[cd]') or s:match('^fe[89ab]') then
return true
end
return false
end
return false
end
-- Derive the sender (source) IP from the original-message headers.
-- Priority: X-Originating-IP, then the client-ip of Received-SPF /
-- Authentication-Results, then the first *public* bracketed IP found while
-- walking the Received chain top-to-bottom (falling back to the first valid
-- IP if every hop is private). Returns (ip_string, provenance) or nil.
local function derive_source_ip(headers, headers_multi)
local xoip = headers['x-originating-ip']
if xoip then
local ip = validate_ip(xoip)
if ip then
return ip, 'x-originating-ip'
end
end
local spf = headers['received-spf']
if spf then
local c = spf:match('[Cc]lient%-[Ii][Pp]=([^;%s]+)')
local ip = validate_ip(c)
if ip then
return ip, 'received-spf'
end
end
local ar = headers['authentication-results']
if ar then
local c = ar:match('[Cc]lient%-[Ii][Pp]=([^;%s]+)') or
ar:match('smtp%.remote%-ip=([^;%s]+)') or
ar:match('[Ss]ender IP is ([%x%.:]+)')
local ip = validate_ip(c)
if ip then
return ip, 'authentication-results'
end
end
local recv = headers_multi and headers_multi['received']
if recv then
local first_valid
for _, hdr in ipairs(recv) do
for cand in hdr:gmatch('%[([^%]]+)%]') do
local ipstr, ipobj = validate_ip(cand)
if ipstr then
if not is_nonpublic_ip(ipobj) then
return ipstr, 'received'
end
first_valid = first_valid or ipstr
end
end
for cand in hdr:gmatch('%(([^%)]+)%)') do
local ipstr, ipobj = validate_ip(cand)
if ipstr then
if not is_nonpublic_ip(ipobj) then
return ipstr, 'received'
end
first_valid = first_valid or ipstr
end
end
end
if first_valid then
return first_valid, 'received'
end
end
return nil
end
-- Derive the arrival (receive) date from the original-message headers: the
-- timestamp of the topmost Received header if present, otherwise the Date
-- header. Returns (date_string, provenance) or nil.
local function derive_arrival_date(headers, headers_multi)
local recv = headers_multi and headers_multi['received']
if recv and recv[1] then
-- The date is the clause following the last ';' of a Received header.
local d = recv[1]:match('.*;%s*(.+)$')
if d then
d = str_trim(d)
if d ~= '' then
return d, 'received'
end
end
end
if headers['date'] and headers['date'] ~= '' then
return headers['date'], 'date'
end
return nil
end
-- Derive the envelope sender from the original-message headers: Return-Path
-- is authoritative in stored headers; otherwise fall back to the smtp.mailfrom
-- reported by Authentication-Results. Returns (addr, provenance) or nil.
local function derive_mail_from(headers)
local rp = strip_angles(headers['return-path'])
if rp and rp ~= '' then
return rp, 'return-path'
end
local ar = headers['authentication-results']
if ar then
local mf = ar:match('smtp%.mailfrom=([^;%s]+)')
if mf and mf ~= '' then
return mf, 'authentication-results'
end
end
return nil
end
-- Derive the envelope recipient from the original-message headers, trying the
-- usual delivery-agent headers in turn. Returns (addr, provenance) or nil.
local function derive_rcpt_to(headers)
local candidates = {
'delivered-to', 'x-delivered-to', 'envelope-to',
'x-envelope-to', 'x-original-to',
}
for _, h in ipairs(candidates) do
local v = strip_angles(headers[h])
if v and v ~= '' then
return v, h
end
end
return nil
end
-- ----------------------------------------------------------------------------
-- DSN (RFC 3464)
-- ----------------------------------------------------------------------------
--[[[
-- @function lua_feedback_parsers.parse_dsn(task)
-- Parse an RFC 3464 Delivery Status Notification from the given task.
--
-- Detection: the task must contain either a `multipart/report` part with
-- `report-type=delivery-status`, or a `message/delivery-status` sub-part.
-- If neither is present, returns `nil`.
--
-- Malformed-body policy: if detection succeeds but the body cannot be
-- parsed into at least one non-empty field block, the function still
-- returns a table (with `recipients = {}`) so that callers can distinguish
-- "not a DSN" (nil) from "a DSN we couldn't fully parse" (table with
-- mostly-nil fields).
--
-- @param {rspamd_task} task message to inspect
-- @return {table|nil} parsed DSN, see module doc for the shape
--]]
function exports.parse_dsn(task)
if not task then
return nil
end
-- Detection: prefer the envelope multipart/report, but also accept a
-- bare message/delivery-status (some MTAs emit non-standard shapes).
local envelope = find_multipart_report(task, 'delivery-status')
local status_part = find_part_by_type(task, 'message', 'delivery-status')
if not envelope and not status_part then
return nil
end
local result = {
reporting_mta = nil,
original_envelope_id = nil,
arrival_date = nil,
received_from_mta = nil,
recipients = {},
original_message = nil,
}
if status_part then
local body = status_part:get_content()
if body then
local blocks = parse_field_blocks(tostring(body))
if #blocks > 0 then
local per_message = blocks[1].fields
result.reporting_mta = per_message['reporting-mta']
result.original_envelope_id = per_message['original-envelope-id']
result.arrival_date = per_message['arrival-date']
result.received_from_mta = per_message['received-from-mta']
for j = 2, #blocks do
local rf = blocks[j].fields
result.recipients[#result.recipients + 1] = {
original_recipient = rf['original-recipient'],
final_recipient = rf['final-recipient'],
action = rf['action'] and rf['action']:lower() or nil,
status = rf['status'],
diagnostic_code = rf['diagnostic-code'],
remote_mta = rf['remote-mta'],
last_attempt_date = rf['last-attempt-date'],
}
end
else
rspamd_logger.debugm(N, task, 'DSN detected but delivery-status body has no parseable blocks')
end
else
rspamd_logger.debugm(N, task, 'DSN detected but delivery-status part content is empty')
end
end
local orig_part = find_original_message_part(task)
if orig_part then
result.original_message = extract_original_message(orig_part)
end
return result
end
-- ----------------------------------------------------------------------------
-- ARF (RFC 5965)
-- ----------------------------------------------------------------------------
--[[[
-- @function lua_feedback_parsers.parse_arf(task)
-- Parse an RFC 5965 Abuse Reporting Format (ARF) feedback report.
--
-- Detection: the task must contain a `multipart/report` part with
-- `report-type=feedback-report` AND a sub-part with
-- `message/feedback-report`. If either is missing, returns `nil`.
--
-- Malformed-body policy: same as `parse_dsn`. If detection succeeds but
-- the feedback-report body is unparseable, a table is still returned (with
-- mostly-nil fields and `reported_uri = {}`).
--
-- Sparse-report enrichment: some providers (notably Microsoft's JMRP) emit a
-- feedback-report block containing only `Feedback-Type`, `User-Agent` and
-- `Version`, leaving `Source-IP`, `Arrival-Date`, `Original-Mail-From` and
-- `Original-Rcpt-To` empty and shipping the useful data only as the embedded
-- original-message headers. For any of those four fields that the report
-- itself did not provide, this function recovers a value from the original
-- headers (Received-SPF / Received / X-Originating-IP for the IP; the topmost
-- Received or Date for the arrival date; Return-Path / Delivered-To for the
-- envelope addresses). When one or more fields are recovered this way, a
-- `derived` sub-table maps each recovered field name to the header it came
-- from, so callers can distinguish reported values from inferred ones.
--
-- @param {rspamd_task} task message to inspect
-- @return {table|nil} parsed ARF, see module doc for the shape
--]]
function exports.parse_arf(task)
if not task then
return nil
end
local envelope = find_multipart_report(task, 'feedback-report')
if not envelope then
return nil
end
local fb_part = find_part_by_type(task, 'message', 'feedback-report')
if not fb_part then
return nil
end
local result = {
feedback_type = nil,
version = nil,
user_agent = nil,
original_mail_from = nil,
original_rcpt_to = nil,
arrival_date = nil,
source_ip = nil,
reported_domain = nil,
reported_uri = {},
authentication_results = nil,
original_envelope_id = nil,
incidents = nil,
original_message = nil,
}
local body = fb_part:get_content()
if body then
local blocks = parse_field_blocks(tostring(body))
if #blocks > 0 then
local f = blocks[1].fields
local fm = blocks[1].fields_multi
result.feedback_type = f['feedback-type'] and f['feedback-type']:lower() or nil
result.version = f['version']
result.user_agent = f['user-agent']
result.original_mail_from = strip_angles(f['original-mail-from'])
result.original_rcpt_to = strip_angles(f['original-rcpt-to'])
result.arrival_date = f['arrival-date'] or f['received-date']
result.source_ip = f['source-ip']
result.reported_domain = f['reported-domain']
result.authentication_results = f['authentication-results']
result.original_envelope_id = f['original-envelope-id']
if f['incidents'] then
local n = tonumber(f['incidents'])
if n then
result.incidents = n
end
end
if fm and fm['reported-uri'] then
for _, v in ipairs(fm['reported-uri']) do
result.reported_uri[#result.reported_uri + 1] = v
end
end
else
rspamd_logger.debugm(N, task, 'ARF detected but feedback-report body has no parseable blocks')
end
else
rspamd_logger.debugm(N, task, 'ARF detected but feedback-report part content is empty')
end
local orig_part = find_original_message_part(task)
if orig_part then
local om, oheaders, oheaders_multi = extract_original_message(orig_part)
if om then
-- RFC 5965 consumers typically only care about Message-ID and From.
result.original_message = {
message_id = om.message_id,
from = om.from,
}
end
-- Enrich sparse reports (e.g. Microsoft JMRP) from the embedded original
-- headers. We only fill fields the report itself left empty, and we record
-- where each value came from in `result.derived` so consumers can tell
-- reported data from inferred data.
if oheaders then
local derived = {}
if not result.source_ip then
local ip, from = derive_source_ip(oheaders, oheaders_multi)
if ip then
result.source_ip = ip
derived.source_ip = from
end
end
if not result.arrival_date then
local d, from = derive_arrival_date(oheaders, oheaders_multi)
if d then
result.arrival_date = d
derived.arrival_date = from
end
end
if not result.original_mail_from then
local mf, from = derive_mail_from(oheaders)
if mf then
result.original_mail_from = mf
derived.original_mail_from = from
end
end
if not result.original_rcpt_to then
local rt, from = derive_rcpt_to(oheaders)
if rt then
result.original_rcpt_to = rt
derived.original_rcpt_to = from
end
end
-- If the reported domain is missing, fall back to the domain of the
-- (possibly derived) envelope sender.
if not result.reported_domain and result.original_mail_from then
local dom = result.original_mail_from:match('@([^@%s>]+)%s*$')
if dom then
result.reported_domain = dom:lower()
derived.reported_domain = 'original_mail_from'
end
end
if next(derived) then
result.derived = derived
end
end
end
return result
end
-- Exposed for unit tests.
exports._parse_field_blocks = parse_field_blocks
exports._strip_angles = strip_angles
return exports