github domainaware/parsedmarc 11.0.2

4 hours ago

Changes

  • Upgrade the mailsuite requirement to >=2.3.2, which sets the IMAPClient version 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 next RUN, but a RUN can only write a whiteout over a layer an earlier instruction already committed: the wheel shipped in every published image and every docker pull downloaded 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 install also 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 as site-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(), and parse_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 a bytes/bytearray/memoryview input is still wrapped in an internally-created BytesIO that 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 to extract_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 now sys.exit(...) throughout the CLI and the maintainer map-tooling scripts. exit is installed into builtins by the site module, not just for interactive sessions, so it isn't guaranteed to exist under python -S or an embedded interpreter that skips site — the failure paths that called it would raise NameError instead of actually exiting. sys.exit is always available and was already used elsewhere in cli.py and find_unknown_base_reverse_dns.py. Also removed the dead, immediately-recomputed index_date stores in the Elasticsearch and OpenSearch aggregate-report savers; behavior is unchanged.

Bug fixes

  • find_unknown_base_reverse_dns.py's missing-file checks for base_reverse_dns_map.csv and the known_unknown/PSL-override lists printed a clean error message but fell through into an unhandled FileNotFoundError traceback instead of exiting.
  • parse_report_file() now closes the file handle it opens itself for a path input if reading it raises. When input_ is a path, the function opened the file, read it, and closed it with no exception handling in between; an exception raised by read() (e.g. an OSError from 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's io.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's py/file-not-closed query flags, found in a local code-quality scan. The path branch now opens the file with a with block, 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's default connection 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 the default alias outright — after which every report save failed with KeyError: "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 default connection 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 unsupported auth_type, awssigv4 without an aws_region, AWS credentials that will not load) — parsedmarc logged Config reload failed, continuing with previous config and 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, and index_prefix_domain_map still 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's default alias restored to exactly the client it named beforehand, and the Elasticsearch serverless flag (which decides whether number_of_shards/number_of_replicas are 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 the ParserConfig, and converted the watch parameters. A failure in any of those (a typo in local_reverse_dns_map_path or local_psl_overrides_path, an unreadable map or overrides file, an IP database that cannot be resolved) was caught and logged as Config 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, opts could be half-applied, and load_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() and get_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, the parsedmarc.utils globals the loaders assign are snapshotted, the replacement log_file is 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. A log_file that 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, a log_file that 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' default connection alias is handed back to the old client, and the utils globals 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.

Don't miss a new parsedmarc release

NewReleases is sending notifications on new releases.