New features
n_procsparallel parsing now covers messages from mbox files and mailbox connections (#147), not just report files passed directly as CLI arguments: IMAP, Microsoft Graph, Gmail API, and Maildir connections, including watch mode.get_dmarc_reports_from_mbox(),get_dmarc_reports_from_mailbox(), andwatch_inbox()all gained ann_procskeyword argument. Parallel workers are now a reused process pool for the whole run, with a bounded submission window that keeps at most roughly2 * n_procsmessages in flight at a time so memory stays bounded even for huge mboxes; the main process sends periodic IMAP keepalives while workers parse.- Added
ParserConfig, a single frozen dataclass carrying every parsing/enrichment option (#503): offline mode, IP database path, reverse DNS map and PSL overrides paths/URLs, DNS nameservers/timeout/retries,strip_attachment_payloads,normalize_timespan_threshold_hours, and the three caches the parser and enrichment code share across calls (IP address info, seen aggregate report IDs, and the reverse DNS map).parse_aggregate_report_xml(),parse_aggregate_report_file(),parse_failure_report(),parse_report_email(),parse_report_file(),get_dmarc_reports_from_mbox(),get_dmarc_reports_from_mailbox(), andwatch_inbox()all accept a keyword-onlyconfig=argument; when it's provided, the individual option keyword arguments are ignored in favor of the config's values, and every existing per-option keyword argument continues to work unchanged whenconfig=is omitted. Every explicitly constructedParserConfigowns fresh, isolated caches; omittingconfig=falls back to the existing process-wide shared default caches, unchanged and identity-preserved (parsedmarc.IP_ADDRESS_CACHE,parsedmarc.SEEN_AGGREGATE_REPORT_IDS,parsedmarc.REVERSE_DNS_MAP). Caches never cross multiprocessing worker boundaries:ParserConfig.__getstate__drops the three cache fields when a config is pickled for a worker, and each worker accumulates its own from that point on.keep_aliveandn_procsremain separate keyword arguments — they control process/worker orchestration, not parsing or enrichment behavior, so they're deliberately notParserConfigfields. See the new "Using parsedmarc as a library" section of the usage docs for an example. - Added a "Domain policy" dropdown filter to the Splunk aggregate DMARC dashboard (#854): filters every panel on the published DMARC policy (
published_policy.p) or subdomain policy (published_policy.sp), making it easy to see which domains with anonepolicy are ready to move toquarantineorreject. It sits immediately before the "Message disposition" dropdown and offers the same choices (any/none/quarantine/reject, defaulting toany). - New
[mailbox]optionsdelete_aggregate,delete_failure,delete_smtp_tls, anddelete_invalid(#256): control message deletion per report type instead of all-or-nothing. Each defaults to the value of the overalldeleteoption, so existing configurations behave exactly as before; set one to override just that type — for exampledelete = Truewithdelete_failure = Falsedeletes processed aggregate and SMTP TLS report messages while archiving failure report messages.delete_invalidcovers messages that could not be parsed, so they can be kept in theInvalidarchive subfolder for debugging even when everything else is deleted.get_dmarc_reports_from_mailbox()andwatch_inbox()gained matchingdelete_aggregate,delete_failure,delete_smtp_tls, anddelete_invalidkeyword arguments, whereNone(the default) means "inheritdelete". The mutual exclusion between deletion andtestmode is now evaluated against the effective per-type flags: any type that would be deleted still raises aValueErroralongsidetest, butdelete = Truewith all four per-type options explicitlyFalseis now valid withtestenabled, since nothing would be deleted. - New
[mailbox]optionmax_unsaved_retries(default2): how many times a batch of messages whose reports could not be saved is retried before its messages are moved to theUnsavedarchive subfolder and stop being retried.0moves messages on the first failed save; negative values are rejected with aValueError. Failures are counted in memory, per message and per process, so the cap applies across watch-mode checks within one long-running process rather than across separate one-shot runs. See the mailbox-persistence bug fix below for the full behavior; the cap is deliberately low because each retry re-delivers the batch to every output destination that does not deduplicate.get_dmarc_reports_from_mailbox()andwatch_inbox()gained a matchingmax_unsaved_retrieskeyword argument. - New
[general]optionarchive_directory: move successfully processed local report files into a dated archive tree (<archive_directory>/<year>/<month>/<Aggregate|Failure|SMTP-TLS>/); files that fail to parse as a report go to<archive_directory>/Invalid/, while files that fail for other reasons (e.g. transient I/O errors) are left in place so a later run can retry them; existing files are never overwritten (numeric suffix); files already under the archive directory are excluded from later runs. Applies only to report files passed directly as local path arguments (#570).
Changes
- The DMARC pass/fail pie chart and over-time chart are now named for DMARC compliance in the OpenSearch Dashboards/Kibana and Splunk dashboards and the Elasticsearch-backed Grafana dashboard (the PostgreSQL Grafana variant has no pass/fail charts and is unchanged): "DMARC compliance" and "DMARC compliance over time" ("DMARC Compliance" / "DMARC Compliance Over Time" in Grafana, which title-cases panel names), replacing the previous mix of "Passed DMARC", "DMARC Passage", and "DMARC passage over time". This matches the existing "% DMARC Compliant" table column and the "Message volume and DMARC compliance by from domain" panels. The Splunk dashboard's pass/fail filter dropdown label was renamed to "DMARC compliant" to match.
- The Grafana dashboard's Guide panel walkthrough now matches the current panel layout, directing readers to the "Top 2000 Message Sources by Reverse DNS" and "Message volume and DMARC compliance by from domain" tables and describing the compliance percentage they show, replacing stale references to a from-domain list "on the right" and a "Message From Header" table that no longer exists.
get_dmarc_reports_from_mailbox()gained an optional keyword-onlysave_callbackparameter, invoked once per fetched batch with aParsingResultsdict holding only that batch's newly parsed reports, after parsing but before any of the batch's messages are deleted or moved out ofreports_folder. ReturningFalsereports the batch as unsaved: its messages stay in place for retry and the aggregate-report dedup cache is not updated for that batch, so the retry reparses those reports rather than skipping them as duplicates. Raising counts as an unsaved batch too — the same retention andmax_unsaved_retriesbookkeeping runs — and the exception is then re-raised, so a callback that fails by raising (the CLI's does, underfail_on_output_error) is still bounded by the retry cap even when the caller's watch loop swallows the exception, as mailsuite's IMAP and Maildir backends do. Any other return value, includingNone, commits the batch exactly as before, which is whatsave_callback=None(the default) does for every batch. As a related, strictly safer side effect of staging the dedup keys per batch, a mid-batch crash now leavesSEEN_AGGREGATE_REPORT_IDSunmodified for that batch instead of partially populated.watch_inbox()'scallbackis now passed through assave_callback, so it runs once per batch before that batch is archived or deleted, with only that batch's reports, rather than once afterward with the whole check's accumulated results. It may returnFalseto defer a batch to the next check.- A single-shot CLI run configured with both file/mbox inputs and a mailbox connection now emits output in two passes instead of one: mailbox-derived reports are saved through the new
save_callbackbeforeget_dmarc_reports_from_mailbox()returns, and file/mbox-derived reports are saved in a second pass afterward.append_json/append_csvare already append-safe and watch mode already called its callback repeatedly, so this changes how often output happens in the single-shot case, not what is written. When a mailbox connection is configured and the file/mbox inputs yielded no reports (none given, or none parsed), the second pass is skipped entirely rather than printing an empty JSON document.
Bug fixes
- Mailbox messages are no longer archived or deleted until the output destinations confirm the reports were saved (#242). Previously
get_dmarc_reports_from_mailbox()archived or deleted every successfully parsed message before the CLI wrote anything to Elasticsearch/OpenSearch/Splunk/S3/Kafka/PostgreSQL/etc., so an output outage meant the report was never persisted anywhere while its source message was already gone from the reports folder, with no way to recover it. Each batch is now written to every configured destination first; if any destination fails, the whole batch's messages stay in the reports folder and are retried on the next run or watch-mode check. This is all-or-nothing per batch (archiving on partial success would still permanently lose the data for whichever destination failed) and applies independently offail_on_output_error, which only controls the process exit code. Unparseable messages carry no report data and are still filed underInvalid(or deleted perdelete_invalid) as before. To bound re-delivery to destinations that do not deduplicate, a message whose batch has failedmax_unsaved_retries + 1times (three by default) is moved to<archive_folder>/Unsavedand stops being retried — never deleted, whatever thedeleteoptions say. Retries can produce duplicates in Kafka, Splunk HEC, syslog, GELF, webhooks, Azure Log Analytics, and the--outputJSON/CSV files; Elasticsearch, OpenSearch, and PostgreSQL dedupe viaAlreadySaved, and S3 overwrites the same key. Thanks to @mkilijanek for the original implementation in #823. extract_report()now accepts plain uncompressed JSON. The JSON magic-byte constant was written asb"\7b", which Python reads as the octal escape\7(BEL,0x07) followed by a literalb— not0x7B, the{every RFC 8259 JSON object begins with — so the JSON branch never matched and plain JSON input was rejected as "Not a valid zip, gzip, json, or xml file". Every in-tree caller pre-guarded with its own zip/gzip or{check, which masked the dead branch; the practical impact was onextract_report()as a public API and onapplication/tlsrpt+gzipemail attachments whose payload is actually uncompressed JSON. Nowb"\x7b".- An SMTP TLS report with an empty
policieslist no longer crashes the CLI whenindex_prefix_domain_mapis configured.parse_smtp_tls_report_json()accepts an empty list, butget_index_prefix()unconditionally indexedpolicies[0], raisingIndexError. Such a report has no domain to map, so it is now treated as unmappable — excluded from prefix-mapped output like any other unmapped-domain report — instead of crashing. - A failed
--outputfile write no longer escapes uncaught.save_output()was the only output destination the CLI did not wrap in a try/except, so a full disk or an unwritable output path crashed the run instead of being recorded like every other destination's failure. It is now caught and recorded as aFile outputerror, which also means it correctly blocks mailbox archiving like any other failure. - The emailed summary (
smtp_host/ Microsoft Graph) once again respectsindex_prefix_domain_mapfiltering of SMTP TLS reports.process_reports()applies that filter in place to the dict it is given, and it is now given each mailbox batch and the file-derived snapshot rather than the combined results — so the combined dict passed toemail_results()no longer inherited the filtering. It is filtered explicitly instead, so a configuration combiningindex_prefix_domain_mapand an email destination excludes unmapped-domain SMTP TLS reports from the emailed summary, matching what was saved. - Fixed the broken "XML files" link on the Splunk docs page: it pointed at the old
splunk/repository path instead ofdashboards/splunk/, where the dashboards have lived since thedashboards/directory was introduced. - A report file whose parsing raised an unexpected non-parser exception no longer hangs the CLI forever. The old direct-file parallel implementation spawned a fresh child process per file and blocked the parent on a pipe read; a child that crashed with anything other than a
ParserErrornever wrote to that pipe, so the parent waited indefinitely. The new implementation returns the error as a value and logsFailed to parse <path>instead. - Direct-file parallel parsing no longer stalls a whole batch on one slow file, and no longer spawns a fresh interpreter per file. The old implementation processed files in hard batches of
n_procs, so a single slow file delayed every other file in its batch; the new pooled-worker implementation streams files through a reused pool instead. - A one-shot mailbox run (no
--watch) now honors[general] dns_timeoutanddns_retries(#503). The CLI's call toget_dmarc_reports_from_mailbox()never forwarded those two options, so every one-shot mailbox run silently used the library's own hardcoded default (dns_timeout=6.0,dns_retries=0) regardless of what the operator configured; watch-mode runs were unaffected since thewatch_inbox()call site did pass them. The CLI now builds a singleParserConfigper run (via the new_build_parser_config()helper) from parsed options and passes it asconfig=to every parsing entry point — the direct-file, mbox, one-shot mailbox, and watch call sites, plus the SIGHUP config-reload path — instead of forwarding option keyword arguments by hand at each call site. - Lazily-triggered reverse DNS map loads no longer silently clobber configured PSL overrides with the bundled defaults (#503).
get_ip_address_info()andget_service_from_reverse_dns_base_domain()load the reverse DNS map on first use rather than eagerly (this also happens independently in eachn_procsworker process, since a worker starts with an empty map), andload_reverse_dns_map()reloadspsl_overrides.txtat the same time so map entries that depend on the current overrides fold correctly. That reload previously calledload_psl_overrides()with no path/URL arguments, so the first lazy map load in a run overwrote any operator-configured PSL overrides file with the bundled defaults.get_ip_address_info()andget_service_from_reverse_dns_base_domain()now acceptpsl_overrides_path/psl_overrides_urlparameters and thread them through toload_reverse_dns_map(), so a lazy load applies the same configured overrides an eager one would. get_dmarc_reports_from_mailbox()andwatch_inbox()'sdns_timeoutdefault changed from a stray6.0toDEFAULT_DNS_TIMEOUT(2.0 seconds,parsedmarc/constants.py), matching every other parsing entry point;normalize_timespan_threshold_hoursalso now defaults to the float24.0on both, rather than the int24. This is a minor behavior change for library callers who invoke either function directly without passingdns_timeout: DNS queries now time out after 2 seconds by default instead of 6, matchingparse_report_file()and the CLI's own default.