Changes
-
Upgrade the
mailsuiterequirement to>=2.3.2, which sets theIMAPClientversion to>=4.0.0, fixing support for Python 3.14 (Closes #838) -
The prebuilt Docker image (
ghcr.io/domainaware/parsedmarc) is roughly 40% smaller to pull (#893). The runtime stage copied the built wheel out of the build stage and deleted it again at the end of the nextRUN, but aRUNcan only write a whiteout over a layer an earlier instruction already committed: the wheel shipped in every published image and everydocker pulldownloaded it (10,713,473 bytes of the 11.0.0 image, on both architectures). The wheel is now bind-mounted from the build stage instead, and a bind mount is never committed to a layer.pip installalso runs with--no-cache-dir, which drops a further ~99 MB of pip's download cache that the image had been carrying in the same layer assite-packages. Measured on linux/amd64: 272,138,724 compressed bytes across six layers before, 163,757,439 across five after. -
parse_report_file(),extract_report(), andparse_aggregate_report_file()no longer close a file object supplied by the caller, on success or failure. A path input is still opened and closed by the function, and abytes/bytearray/memoryviewinput is still wrapped in an internally-createdBytesIOthat the function closes; but a file-like object the caller passed in may be seeked,tell()'d, or reused afterward, and closing it out from under the caller (as these functions previously did on the success path) makes that impossible.parse_aggregate_report_file()forwards its input straight toextract_report(), so it inherits the fix without changes of its own.extract_report()'s handling of a caller-supplied non-seekable stream is unaffected: its contents are still copied into an internally-owned buffer that the function closes, since that buffer was never the caller's own handle. -
Bare
exit(...)calls are nowsys.exit(...)throughout the CLI and the maintainer map-tooling scripts.exitis installed intobuiltinsby thesitemodule, not just for interactive sessions, so it isn't guaranteed to exist underpython -Sor an embedded interpreter that skipssite— the failure paths that called it would raiseNameErrorinstead of actually exiting.sys.exitis always available and was already used elsewhere incli.pyandfind_unknown_base_reverse_dns.py. Also removed the dead, immediately-recomputedindex_datestores in the Elasticsearch and OpenSearch aggregate-report savers; behavior is unchanged.
Bug fixes
find_unknown_base_reverse_dns.py's missing-file checks forbase_reverse_dns_map.csvand theknown_unknown/PSL-override lists printed a clean error message but fell through into an unhandledFileNotFoundErrortraceback instead of exiting.parse_report_file()now closes the file handle it opens itself for a path input if reading it raises. Wheninput_is a path, the function opened the file, read it, and closed it with no exception handling in between; an exception raised byread()(e.g. anOSErrorfrom the underlying storage) skipped the close, so the descriptor was left to be released only when Python's garbage collector eventually finalized the object — CPython'sio.IOBase.__del__closes an unclosed file on finalization (https://docs.python.org/3/library/io.html) — rather than being closed deterministically. This is the pattern CodeQL'spy/file-not-closedquery flags, found in a local code-quality scan. The path branch now opens the file with awithblock, so the handle is closed on both the success and exception paths. (A caller-supplied file-like object's closing behavior changed again later in this same Unreleased version — see the Changes section above.)- A SIGHUP configuration reload no longer breaks every subsequent save to Elasticsearch or OpenSearch. With
[elasticsearch]or[opensearch]configured in watch mode, reloading the configuration re-registered the search client under the client library'sdefaultconnection alias and then closed the previous run's clients. The close step re-resolved that alias instead of remembering the client it was created for, so it closed the newly built client and deleted thedefaultalias outright — after which every report save failed withKeyError: "There is no connection with alias 'default'."until parsedmarc was restarted, and the original client was left open. Each backend's handle now closes the exact client it was created for and gives up the alias only while the alias still points at that client. Both the Elasticsearch and OpenSearch backends were affected. - A configuration reload that fails part-way no longer leaves reports being written to the new Elasticsearch or OpenSearch hosts under the old configuration. The search client is registered under the client library's process-wide
defaultconnection alias as soon as it is constructed — before the index migration runs, and before the outputs configured after it are created. When a later step then failed on a SIGHUP reload — for example an[opensearch]section that cannot build its client (an unsupportedauth_type,awssigv4without anaws_region, AWS credentials that will not load) — parsedmarc loggedConfig reload failed, continuing with previous configand kept the old configuration. But the alias had already been handed to the new client, so every subsequent save resolved it to the new hosts while the index prefixes, suffixes, andindex_prefix_domain_mapstill came from the old configuration. Reports were silently written to a destination that was never successfully configured, with the log saying nothing had changed. The half-built client was never closed either, nor were the other clients (S3, Kafka, PostgreSQL, and so on) built earlier in the same failed reload — the same leak occurred on every attempt of the startup retry loop, which calls the same function. Building the output clients is now all-or-nothing: if any step fails, everything built so far is closed and the module-level state the search backends keep is put back — each configured backend'sdefaultalias restored to exactly the client it named beforehand, and the Elasticsearchserverlessflag (which decides whethernumber_of_shards/number_of_replicasare sent when an index is created) to its old value — so a failed reload no longer changes where reports are written, or how indexes are created. - A SIGHUP configuration reload that fails after the replacement output clients are built now really does keep the previous configuration, instead of leaving the new clients live and the old ones closed. The reload built the replacement clients, then immediately closed the old ones and swapped them in — and only afterwards reloaded the reverse DNS map and the PSL overrides, reloaded the IP database, re-applied the IPinfo API token, copied the new values onto
opts, rebuilt theParserConfig, and converted the watch parameters. A failure in any of those (a typo inlocal_reverse_dns_map_pathorlocal_psl_overrides_path, an unreadable map or overrides file, an IP database that cannot be resolved) was caught and logged asConfig reload failed, continuing with previous config, which was not true: the new output clients were already receiving every report, the old clients were closed and could not be brought back,optscould be half-applied, andload_reverse_dns_map()/load_psl_overrides()— both of which empty their target before reading anything — had left one of the two empty. A bad map path emptied the reverse DNS map, so reverse DNS lookups stopped resolving to known services until some later uncached lookup happened to refill the map from the previous configuration's paths (get_service_from_reverse_dns_base_domain()andget_ip_address_info()both reload a map they find empty). A bad PSL overrides path emptied the override list without ever touching the map, so nothing triggered that lazy reload and base domains were folded without the overrides until parsedmarc was restarted. The whole reload is now staged before anything live is touched: the map is loaded into a fresh dict, theparsedmarc.utilsglobals the loaders assign are snapshotted, the replacementlog_fileis opened during staging, and the commit is a run of plain assignments followed by the logging refresh, whose one remaining failure point — closing the replaced log file — is caught and logged rather than allowed to abort a reload that is already live. Alog_filethat cannot be opened now aborts the reload with the previous log file still attached and still receiving logs; previously the old handler had already been removed, the failed open was only a warning, file logging stayed dead, and a later reload of the same path was a no-op that never retried it. Relatedly, alog_filethat could not be opened at startup — a warning there, since there is no previous configuration to keep — is now retried by the next reload even when the setting is unchanged; startup used to record the unopened file as the active one, so the reload saw nothing to do. On failure the replacement clients are closed, the log file opened for the reload is closed (best-effort, so a close error cannot skip the restores that follow it), the search backends'defaultconnection alias is handed back to the old client, and theutilsglobals are restored, so the log message is accurate. The clients the reload replaces are now closed last, after the new configuration is live, and a failure to close one is logged without disturbing the reload. #906 fixed the other half of this — building the clients themselves is all-or-nothing.