diff --git a/configure.ac b/configure.ac index e12de56..bac6f66 100644 --- a/configure.ac +++ b/configure.ac @@ -593,6 +593,8 @@ AC_CONFIG_FILES([ Makefile opendmarc/opendmarc-check.8 opendmarc/tests/Makefile reports/Makefile + reports/opendmarc-bounce-handler + reports/opendmarc-bounce-handler.8 reports/opendmarc-expire reports/opendmarc-expire.8 reports/opendmarc-import diff --git a/db/README.schema b/db/README.schema index 8165687..5fb24fb 100644 --- a/db/README.schema +++ b/db/README.schema @@ -53,7 +53,14 @@ suppressions A table of RUA/RUF addresses and domains that should be manually or automatically from VERP bounce processing: when opendmarc-reports sends reports with --verp, bounces return to an encoded envelope sender that identifies the failing - address, allowing a bounce handler to INSERT the address here. + address, and opendmarc-bounce-handler (run as the mail + alias/pipe target for that address) decodes it and INSERTs it + here. For aggregate reports, the envelope sender also encodes + the domain the report concerned, which opendmarc-bounce-handler + also recovers and can use to prompt a third-party service like + StaleMARC to retest that domain's DMARC reporting + deliverability, without needing to look up which domain(s) + route through the failing address. UPGRADING AN EXISTING SCHEMA diff --git a/reports/.gitignore b/reports/.gitignore index e109bb1..a3bf525 100644 --- a/reports/.gitignore +++ b/reports/.gitignore @@ -1,6 +1,8 @@ *.orig *.rej Makefile.in +opendmarc-bounce-handler +opendmarc-bounce-handler.8 opendmarc-expire opendmarc-expire.8 opendmarc-import diff --git a/reports/Makefile.am b/reports/Makefile.am index b339140..c452b65 100644 --- a/reports/Makefile.am +++ b/reports/Makefile.am @@ -5,12 +5,12 @@ AUTOMAKE_OPTIONS = foreign dist_doc_DATA = README opendmarc-run.conf.sample -dist_sbin_SCRIPTS = opendmarc-expire opendmarc-import opendmarc-importstats \ - opendmarc-params opendmarc-reports +dist_sbin_SCRIPTS = opendmarc-bounce-handler opendmarc-expire opendmarc-import \ + opendmarc-importstats opendmarc-params opendmarc-reports libexec_SCRIPTS = opendmarc-run CLEANFILES = opendmarc-importstats -dist_man_MANS = opendmarc-expire.8 opendmarc-import.8 opendmarc-params.8 \ - opendmarc-reports.8 opendmarc-importstats.8 +dist_man_MANS = opendmarc-bounce-handler.8 opendmarc-expire.8 opendmarc-import.8 \ + opendmarc-params.8 opendmarc-reports.8 opendmarc-importstats.8 diff --git a/reports/opendmarc-bounce-handler.8.in b/reports/opendmarc-bounce-handler.8.in new file mode 100644 index 0000000..5393b68 --- /dev/null +++ b/reports/opendmarc-bounce-handler.8.in @@ -0,0 +1,138 @@ +.TH opendmarc-bounce-handler 8 "The Trusted Domain Project" +.SH NAME +.B opendmarc-bounce-handler +\- process bounces of VERP'd DMARC reports +.SH SYNOPSIS +.B opendmarc-bounce-handler +[options] +.SH DESCRIPTION +.B opendmarc-reports +.RI ( 8 ), +when run with +.IR --verp , +encodes the failing recipient address -- and, for aggregate reports, the +domain the report concerned -- into the envelope sender of each report it +sends, so that a bounce returns to an address identifying what failed. +See +.B opendmarc-reports (8) +under +.I --verp +for the exact encoding. +.PP +.B opendmarc-bounce-handler +decodes that information back out of a bounce. It is meant to be run as +the pipe target of a mail alias for the mailbox +.I --report-email +uses, e.g. in +.IR /etc/aliases : +.sp +.in +4n +postmaster: "|@sbindir@/opendmarc-bounce-handler" +.in +.sp +When invoked this way, the bounce (or DSN) is read from standard input and +the VERP-encoded recipient is recovered from its +.IR Original-Recipient , +.IR Final-Recipient , +.IR X-Original-To , +.IR Delivered-To ", or" +.I To +fields, in that order of preference. If the MTA can instead pass the +original recipient directly (for example, a Postfix +.I pipe +transport configured with +.IR ${original_recipient} ), +supply it with +.I --address +and standard input is ignored. +.PP +Once decoded, +.B opendmarc-bounce-handler +inserts the failing address into the +.I suppressions +table so subsequent +.B opendmarc-reports +runs skip it, and, if a domain was also recovered, optionally requests +StaleMARC's +.I checkDomain +endpoint for that domain to prompt a recheck of its DMARC reporting +deliverability. +.SH OPTIONS +.TP +.I --address=tag +Decode this VERP tag (the local-part extension after the first +.RI ' + ' +in the envelope recipient the bounce concerns) instead of scanning +standard input for it. +.TP +.I --dbscheme=scheme +Specify "mysql" (the default) or "MariaDB". +.TP +.I --dbhost=host +Attempts to connect to the database server on the named +.I host. +The default is "localhost". +.TP +.I --dbname=name +Requests a connection to the database called +.I name. +The default is "opendmarc". +.TP +.I --dbpasswd=password +Attempts to authenticate to the database server using the specified +.I password. +The default is "opendmarc". +.TP +.I --dbport=port +Tries to connect to the database at the specified TCP +.I port. +The default is 3306. +.TP +.I --dbuser=user +Attempts to authenticate to the database server as the specified +.I user. +The default is "opendmarc". +.TP +.I --help +Prints a usage message and exits. +.TP +.I --reason=text +The value to record in the +.I suppressions +table's +.I reason +column. The default is "bounce". +.TP +.I --stale-notify +Prompts StaleMARC to retest the recovered domain's DMARC reporting +deliverability. Enabled by default; disable with +.IR --no-stale-notify . +Has no effect when no domain was recovered (e.g. a forensic report bounce, +or an older aggregate report sent before domain encoding was added). +.TP +.I --stale-server=url +StaleMARC API base URL. +.TP +.I --test +Decodes and prints what would be done, without touching the database or +contacting StaleMARC. +.TP +.I --verbose +Requests verbose output. +.TP +.I --version +Prints version number and exits. +.SH EXIT STATUS +Exits non-zero only on a database connection or query failure. A bounce +from which nothing could be decoded is logged to standard error and +otherwise ignored (exit status 0), so a malformed or unrelated piece of +mail delivered to the same alias cannot itself trigger further bounces. +.SH VERSION +This man page covers the version of +.I opendmarc-bounce-handler +that shipped with version @VERSION@ of +.I OpenDMARC. +.SH COPYRIGHT +Copyright (c) 2026, The Trusted Domain Project. All rights reserved. +.SH SEE ALSO +.I opendmarc-reports(8) diff --git a/reports/opendmarc-bounce-handler.in b/reports/opendmarc-bounce-handler.in new file mode 100644 index 0000000..750883a --- /dev/null +++ b/reports/opendmarc-bounce-handler.in @@ -0,0 +1,316 @@ +#!@PERL@ +# +# Copyright (c) 2026, The Trusted Domain Project. All rights reserved. +# +# Mail delivery agent for bounces of VERP'd DMARC reports sent by +# opendmarc-reports --verp. Meant to be invoked as the pipe target of a +# mail alias for the report envelope-sender mailbox (i.e. the local part +# of --report-email), e.g. in /etc/aliases: +# +# postmaster: "|@sbindir@/opendmarc-bounce-handler" +# +# Recovers the failing RUA/RUF address -- and, for aggregate reports, the +# domain the report concerned -- from the VERP-encoded recipient the +# bounce was addressed to, records the failing address in the +# suppressions table, and (for aggregate reports) optionally prompts +# StaleMARC to (re-)test the recovered domain's DMARC reporting +# deliverability. + +### +### Setup +### + +use strict; +use warnings; + +use DBI; +use File::Basename; +use Getopt::Long; +use HTTP::Tiny; +use POSIX; + +# general +my $progname = basename($0); +my $version = "@VERSION@"; +my $verbose = 0; +my $helponly = 0; +my $showversion = 0; +my $test = 0; + +my $address; +my $reason = "bounce"; + +# DB parameters +my $def_dbscheme = "@SQL_BACKEND@"; +my $def_dbhost = "localhost"; +my $def_dbname = "opendmarc"; +my $def_dbuser = "opendmarc"; +my $def_dbpasswd = "opendmarc"; +my $def_dbport = "3306"; +my $dbhost; +my $dbname; +my $dbuser; +my $dbpasswd; +my $dbport; + +my $dbscheme = $def_dbscheme; + +# StaleMARC +my $stale_notify = 1; +my $stale_server = "https://stalemarc.measurement.network/api"; + +my $dbi_h; +my $dbi_s; + +### +### NO user-serviceable parts beyond this point +### + +sub usage +{ + print STDERR "$progname: usage: $progname [options]\n"; + print STDERR "\t--address=tag decode this VERP tag instead of scanning stdin\n"; + print STDERR "\t--dbscheme=scheme mysql or MariaDB [$def_dbscheme]\n"; + print STDERR "\t--dbhost=host database host [$def_dbhost]\n"; + print STDERR "\t--dbname=name database name [$def_dbname]\n"; + print STDERR "\t--dbpasswd=passwd database password [$def_dbpasswd]\n"; + print STDERR "\t--dbport=port database port [$def_dbport]\n"; + print STDERR "\t--dbuser=user database user [$def_dbuser]\n"; + print STDERR "\t--help print help and exit\n"; + print STDERR "\t--reason=text suppressions.reason to record [$reason]\n"; + print STDERR "\t--stale-notify prompt StaleMARC to retest the recovered\n"; + print STDERR "\t domain (default; use --no-stale-notify\n"; + print STDERR "\t to disable)\n"; + print STDERR "\t--stale-server=url StaleMARC API base URL\n"; + print STDERR "\t--test decode and report only; don't touch the\n"; + print STDERR "\t database or contact StaleMARC\n"; + print STDERR "\t--verbose verbose output\n"; + print STDERR "\t--version print version and exit\n"; +} + +# Reverses verp_sender()'s encoding (see reports/opendmarc-reports.in). +# $tag is the local-part extension a bounce was delivered to, i.e. +# everything after the first '+' in the envelope recipient: +# +# dmarc-rua=aggregator.com (forensic, or pre-domain) +# example.com+dmarc-rua=aggregator.com (aggregate, with domain) +# +# Returns ($domain, $address); $domain is undef if none was encoded. +# +# This is a heuristic, not an exact inverse: if the failing RUA/RUF +# address's own local part contains a '+', a tag with no domain will +# contain one too, and will be misread as domain+address. That case is +# rare (report addresses are operator-chosen), and worth noting to anyone +# troubleshooting a decode that looks wrong. +sub decode_verp +{ + my ($tag) = @_; + + return (undef, undef) unless defined($tag) && length($tag); + + my ($first, $rest) = split(/\+/, $tag, 2); + + if (defined($rest) && $first !~ /=/ && $first =~ /\./ && $rest =~ /=/) + { + (my $addr = $rest) =~ s/=/\@/; + return ($first, $addr); + } + + (my $addr = $tag) =~ s/=/\@/; + return (undef, $addr); +} + +# Scans lines of a bounce message (a DSN's per-message/per-recipient +# fields, or a plain header block) for the VERP address the report was +# sent to, and returns its local-part extension (the tag decode_verp() +# expects), or undef if none was found. +sub find_tag +{ + my (@lines) = @_; + + my @candidates; + + for (@lines) + { + if (/^(?:Original-Recipient|Final-Recipient)\s*:\s*(?:rfc822;)?\s*(.+?)\s*$/i) + { + push @candidates, $1; + } + elsif (/^(?:X-Original-To|Delivered-To|To)\s*:\s*(.+?)\s*$/i) + { + push @candidates, $1; + } + } + + for my $addr (@candidates) + { + $addr =~ s/^.*.*$//; + + if ($addr =~ /^[^@+\s]+\+([^@\s]+)@/) + { + return $1; + } + } + + return undef; +} + +# parse command line arguments +my $opt_retval = &Getopt::Long::GetOptions ('address=s' => \$address, + 'dbscheme=s' => \$dbscheme, + 'dbhost=s' => \$dbhost, + 'dbname=s' => \$dbname, + 'dbpasswd=s' => \$dbpasswd, + 'dbport=s' => \$dbport, + 'dbuser=s' => \$dbuser, + 'help!' => \$helponly, + 'reason=s' => \$reason, + 'stale-notify!' => \$stale_notify, + 'stale-server=s' => \$stale_server, + 'test!' => \$test, + 'verbose!' => \$verbose, + 'version!' => \$showversion, + ); + +if (!$opt_retval || $helponly) +{ + usage(); + + if ($helponly) + { + exit(0); + } + else + { + exit(1); + } +} + +if ($showversion) +{ + print STDOUT "$progname v$version\n"; + exit(0); +} + +# apply defaults +if (!defined($dbhost)) +{ + $dbhost = defined($ENV{'OPENDMARC_DBHOST'}) ? $ENV{'OPENDMARC_DBHOST'} : $def_dbhost; +} + +if (!defined($dbname)) +{ + $dbname = defined($ENV{'OPENDMARC_DB'}) ? $ENV{'OPENDMARC_DB'} : $def_dbname; +} + +if (!defined($dbpasswd)) +{ + $dbpasswd = defined($ENV{'OPENDMARC_PASSWORD'}) ? $ENV{'OPENDMARC_PASSWORD'} : $def_dbpasswd; +} + +if (!defined($dbport)) +{ + $dbport = defined($ENV{'OPENDMARC_PORT'}) ? $ENV{'OPENDMARC_PORT'} : $def_dbport; +} + +if (!defined($dbuser)) +{ + $dbuser = defined($ENV{'OPENDMARC_USER'}) ? $ENV{'OPENDMARC_USER'} : $def_dbuser; +} + +# +# Let's go! +# + +my $tag = $address; + +if (!defined($tag)) +{ + my @lines = ; + $tag = find_tag(@lines); +} + +if (!defined($tag)) +{ + print STDERR "$progname: no VERP-encoded recipient found; nothing to do\n"; + exit(0); +} + +my ($domain, $failaddr) = decode_verp($tag); + +if (!defined($failaddr) || $failaddr !~ /\@/) +{ + print STDERR "$progname: couldn't decode a report address from '$tag'\n"; + exit(0); +} + +if ($verbose || $test) +{ + print STDERR "$progname: decoded address=$failaddr" . + (defined($domain) ? ", domain=$domain" : "") . "\n"; +} + +if ($test) +{ + exit(0); +} + +require "DBD/$dbscheme.pm"; + +my $dbi_dsn = "DBI:" . $dbscheme . ":database=" . $dbname . + ";host=" . $dbhost; +if ($dbport != $def_dbport) { + $dbi_dsn .= ";port=" . $dbport; +} + +$dbi_h = DBI->connect($dbi_dsn, $dbuser, $dbpasswd, { PrintError => 0 }); +if (!defined($dbi_h)) +{ + print STDERR "$progname: unable to connect to database: $DBI::errstr\n"; + exit(1); +} + +if ($verbose) +{ + print STDERR "$progname: connected to database\n"; +} + +$dbi_s = $dbi_h->prepare("INSERT IGNORE INTO suppressions (address, reason) VALUES (?, ?)"); +if (!$dbi_s->execute($failaddr, $reason)) +{ + print STDERR "$progname: INSERT failed: " . $dbi_h->errstr . "\n"; + $dbi_s->finish; + $dbi_h->disconnect; + exit(1); +} + +if ($verbose) +{ + if ($dbi_s->rows > 0) + { + print STDERR "$progname: suppressed $failaddr\n"; + } + else + { + print STDERR "$progname: $failaddr already suppressed\n"; + } +} + +$dbi_s->finish; +$dbi_h->disconnect; + +if ($stale_notify && defined($domain)) +{ + (my $enc_domain = $domain) =~ s/@/%40/g; + my $url = "$stale_server/checkDomain.php?key=$enc_domain"; + my $resp = HTTP::Tiny->new(timeout => 10)->get($url); + + if ($verbose) + { + print STDERR "$progname: StaleMARC: prompted recheck of $domain " . + "(status $resp->{status})\n"; + } +} + +exit(0); diff --git a/reports/opendmarc-reports.8.in b/reports/opendmarc-reports.8.in index 3252f91..4a2ef03 100644 --- a/reports/opendmarc-reports.8.in +++ b/reports/opendmarc-reports.8.in @@ -322,6 +322,24 @@ identify the specific destination that failed, e.g.: postmaster+dmarc-rua=example.com@reporter.example.com .in .sp +For aggregate reports, the domain the report is +.I about +is also encoded ahead of the recipient, so a bounce-processing script can +recover it without having to look up which domain(s) use a given RUA +address, e.g.: +.sp +.in +4n +postmaster+example.com+dmarc-rua=aggregator.com@reporter.example.com +.in +.sp +(here the report concerns +.I example.com +but is delivered to a third-party aggregator's RUA address). Forensic +report VERP addresses are not currently annotated with a domain, since +.I --forensic +mode relays a pre-formed AFRF message without a readily-available +"domain being reported on" of its own. +.sp In forensic mode, enabling VERP causes one SMTP transaction to be issued per recipient rather than grouping all recipients into a single transaction. .TP @@ -371,9 +389,17 @@ INSERT INTO suppressions (address, reason) When .I --verp is enabled, DMARC report bounces return to an encoded envelope sender -that identifies the failing address. A bounce-processing script can -decode that address and insert it into this table automatically, building -a locally-maintained suppression list informed by real delivery failures. +that identifies the failing address (and, for aggregate reports, the +domain the report concerned). +.B opendmarc-bounce-handler(8) +decodes that address, inserts the failing address into this table +automatically, and, for the recovered domain, optionally prompts a +third-party service such as StaleMARC (see +.IR --stale-check ) +to (re-)test that domain's DMARC reporting deliverability by requesting +its +.I checkDomain +endpoint for it. .SH EXTERNAL DESTINATION VERIFICATION Before sending to a cross-domain .RI ( mailto: ) @@ -457,4 +483,5 @@ All rights reserved. .SH SEE ALSO .IR opendmarc(8) , .IR opendmarc.conf(5) , -.IR opendmarc-import(8) +.IR opendmarc-import(8) , +.IR opendmarc-bounce-handler(8) diff --git a/reports/opendmarc-reports.in b/reports/opendmarc-reports.in index df1a722..270df73 100755 --- a/reports/opendmarc-reports.in +++ b/reports/opendmarc-reports.in @@ -575,14 +575,22 @@ sub archive_report } } -# Build a VERP envelope sender encoding $rcpt into $sender. -# e.g. postmaster@r.example.com + dmarc@target.com -> postmaster+dmarc=target.com@r.example.com +# Build a VERP envelope sender encoding $rcpt (and, optionally, the domain +# being reported on) into $sender. +# e.g. postmaster@r.example.com + dmarc@target.com +# -> postmaster+dmarc=target.com@r.example.com +# e.g. postmaster@r.example.com + dmarc@aggregator.com + example.com +# -> postmaster+example.com+dmarc=aggregator.com@r.example.com +# Including the reported-on domain lets a bounce handler recover it directly +# from the envelope sender of a returned report, without having to look up +# which domain(s) use a given RUA/RUF address. sub verp_sender { - my ($sender, $rcpt) = @_; + my ($sender, $rcpt, $domain) = @_; my ($sloc, $sdom) = split(/@/, $sender, 2); (my $enc = $rcpt) =~ s/@/=/; - return "$sloc+$enc\@$sdom"; + my $tag = (defined($domain) && $domain ne "") ? "$domain+$enc" : $enc; + return "$sloc+$tag\@$sdom"; } sub usage @@ -629,7 +637,9 @@ sub usage print STDERR "\t--stale-server=url StaleMARC API base URL\n"; print STDERR "\t--test don't send reports\n"; print STDERR "\t--verp use VERP envelope senders so bounces\n"; - print STDERR "\t identify the failing recipient\n"; + print STDERR "\t identify the failing recipient (and,\n"; + print STDERR "\t for aggregate reports, the reported-on\n"; + print STDERR "\t domain)\n"; print STDERR "\t (implies --keepfiles --noupdate)\n"; print STDERR "\t--utc operate in UTC\n"; print STDERR "\t--verbose verbose output\n"; @@ -1996,7 +2006,7 @@ foreach (@$domainset) exit(1) unless defined($smtp); } - my $mailfrom = $verp ? verp_sender($repemail, $repdest) : $repemail; + my $mailfrom = $verp ? verp_sender($repemail, $repdest, $domain) : $repemail; if (!$smtp->mail($mailfrom) || !$smtp->to($repdest) || (defined($repbcc) && !$smtp->to($repbcc)) ||