Commit Graph
372 Commits
Author SHA1 Message Date
Sean WhalenandClaude Fable 5 07b00a931b Warn on unmatched CLI paths; send Kafka failure/SMTP TLS reports per-report (#890)
Fixes the two remaining bugs and the nits uncovered during the review
session:

- The CLI logs a warning for each file_path argument that matches no
  files, instead of silently succeeding with empty results — a typo'd
  path in a cron job previously 'worked' forever while processing
  nothing (_expand_file_path_args dropped it as a zero-match glob).
  Deliberately a warning, not an error: a glob legitimately matching
  nothing must not break existing workflows, and mailbox-only runs
  stay silent.

- save_failure_reports_to_kafka and save_smtp_tls_reports_to_kafka now
  send one message per report, mirroring the aggregate saver's
  per-slice sends. Every released version documented per-record sends,
  but the code sent the whole list as one message, which a large batch
  — failure reports carry message samples — could push past Kafka's
  default 1MB message limit. Consumer-visible: these topics now carry
  individual report objects, not one JSON array per batch.

- The CLI module docstring and argparse description now mention SMTP
  TLS reports; usage.md's CLI-help mirror regenerated.

- The elastic/opensearch to_header display-name tests now assert the
  joined 'RT <rcpt@example.com>' string reaches the saved document
  (autospec save), plus the exactly-once call, instead of only that
  save ran.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:23:07 -04:00
Sean WhalenandClaude Fable 5 2d76de9ca6 Fix ignored number_of_replicas and add a --dns-timeout alias (#889)
Two fixes from the #888 review cycle's flagged items:

- [elasticsearch]/[opensearch] number_of_replicas is no longer ignored
  when number_of_shards is not also set. The parser read replicas only
  inside the shards branch — accidental nesting introduced in the
  6.4.0-era code (1c9a6c4) — while docs/source/usage.md lists the two
  options independently and elastic.py/opensearch.py accept them as
  independent parameters with independent defaults (shards=1,
  replicas=0). Regression tests cover the replicas-only case for both
  sections.

- The CLI accepts --dns-timeout as an alias of --dns_timeout, which is
  kept unchanged for backward compatibility (public since 6.0.0;
  --dns-retries, added in 9.7.1, already hyphenated). The end-to-end
  test exercises both spellings through a real _main() run, so dropping
  either option string fails the suite. usage.md's CLI-help block is
  regenerated to match the new --help output.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:56:14 -04:00
07bca1ad28 Make the output and mailbox integrations optional extras (#888)
* Make the output and mailbox integrations optional extras (#883)

Breaking change for the next major release: pip install parsedmarc now
installs the parsing core plus a working core CLI (file, IMAP, Maildir,
and mbox input; CSV/JSON, Splunk HEC, webhook, and syslog output).
Everything else moves behind an extra: elastic, opensearch, kafka, s3,
gelf, loganalytics, msgraph, and gmail, joining the existing postgresql
extra, with an umbrella [all] that deliberately excludes postgresql
(psycopg's binary wheels do not exist on every platform, so
parsedmarc[all] must never fail to install there).

cli.py imports the six SDK-dependent output modules behind the #884
TYPE_CHECKING/try-except guard; a configured section whose extra is
missing fails fast with a ConfigurationError naming the section and the
exact pip install command — including the msgraph and gmail_api mailbox
sections (detected via parsedmarc.mail's placeholder classes) and
postgresql (checked before the constructor so the startup retry loop
does not retry a missing dependency for a minute). The Azure/kiota Graph
error types fall back to never-raised sentinel classes.

The Docker image installs [all,postgresql], so container users see no
change. CI lint installs [build,all,postgresql]; the unit-test job
installs [build,all], deliberately without postgresql so
test_postgres.py's absent-psycopg arm stays exercised. The
never-imported dateparser dependency is dropped in favor of declaring
python-dateutil, which utils.py actually imports; pytz moves to the
build extra for the one test that uses it.

Verified live: a no-extras wheel install imports, parses samples, and
reports the install hint for each gated section; a [all] install
restores every integration; the Docker image builds with every SDK
importable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Patch psycopg presence in the PostgreSQL CLI wiring tests

CI's unit-test job deliberately installs [build,all] without the
postgresql extra, so parsedmarc.cli.postgres.psycopg is None there and
the new missing-extra presence check correctly made _main exit 1 before
the wiring under test ran. The tests simulate the SDK being available
(PostgreSQLClient is mocked at the SDK boundary), so the module-level
psycopg handle is now patched present in setUp. Verified against a
simulated psycopg-absent environment as well as the local full install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot review: narrow guards to ModuleNotFoundError, fix docs

- The optional-integration and Graph error-type import guards now catch
  ModuleNotFoundError instead of ImportError, so only a genuinely absent
  package reads as a missing extra; a broken-but-present SDK fails
  loudly with its real error instead of masquerading as one. The test
  blocker raises ModuleNotFoundError accordingly — the exact exception a
  missing package produces.
- _missing_extra_hint docstring no longer calls every gated integration
  an output module (it also serves the msgraph/gmail_api mailbox
  sections).
- Fix the pre-existing passsword typo in usage.md's kafka section; the
  INI key the code reads is password (cli.py _parse_config).

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Quote extras specs in copy-paste install commands

From Copilot's second review round: zsh treats an unquoted .[build,all]
as a glob and fails with 'no matches found', so the commands shown in
AGENTS.md, CONTRIBUTING.md, dashboards/README.md, and the bootstrap
script's comment are now quoted. The CI workflows keep the unquoted
form: they run under bash, which passes unmatched globs through
literally. The suggestion to change the 'Choosing what to install'
heading level was rejected — it is a subsection of 'Installing
parsedmarc', matching the file's existing hierarchy.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix upgrade command in the changelog

* Documentation review: accuracy, spelling, grammar, and clarity pass

A full prose review of docs/source, README, CONTRIBUTING, and the
dashboards README, with every accuracy claim verified against the code
before changing it. Highlights:

- usage.md: documented six missing [general] options (the CSV/JSON
  filename options, prettify_json, normalize_timespan_threshold_hours),
  the required kafka smtp_tls_topic, [imap] timeout/max_retries, and
  the postgresql env-var prefix; corrected the maildir_path default
  (None, not INBOX — cli.py Namespace defaults), the mailbox
  check_timeout option name, the systemd restart interval (RestartSec
  is 5m), and merged the duplicate silent entry; quoted every
  copy-paste extras spec for zsh safety.
- elasticsearch.md: fixed an invalid openssl command (rsa:4096 -nodes),
  the dashboards filename (opensearch_dashboards.ndjson, matching the
  file the link serves), and assorted grammar.
- davmail.md: the service-enable command now enables davmail.service
  (was parsedmarc.service — a copy-paste error that left DavMail
  unenabled), plus a view typo and DavMail capitalization.
- output.md: the example schema reference is RFC 7489 Appendix C
  (7480 is RDAP). kibana.md: SPF relies on the SMTP envelope, not
  session headers (RFC 7208). dmarc.md: DKM -> DKIM.
- README: the intro now also names the OpenSearch/Grafana stack,
  matching the feature list. CONTRIBUTING: pre-PR checks now include
  ruff format --check and pyright, matching CI's lint job.
- dashboards/README: the service table and seed description now include
  the PostgreSQL backend the compose stack runs.

Sample data blocks, the CLI-help mirror block, and released CHANGELOG
entries were deliberately left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Docstring review: accuracy, spelling, grammar, and clarity pass

Every docstring in parsedmarc/, parsedmarc/mail/, the maps maintainer
scripts, and the test suite reviewed with each claim verified against
the code it documents. Text-only — no behavior changes. Highlights:

- Copy-paste errors corrected: parsed_smtp_tls_reports_to_csv and
  splunk/loganalytics save functions described aggregate or failure
  reports they do not handle; LogAnalyticsException claimed to be an
  Elasticsearch error.
- Docstring/behavior mismatches: parse_report_email's report_type
  enumeration omitted smtp_tls; parse_failure_report typed msg_date as
  str (it is datetime); strip_attachment_payloads claimed payloads are
  replaced with None (the key is deleted); kafkaclient's failure and
  SMTP TLS savers claimed per-record slicing while sending the whole
  list in one message (docstrings now describe reality — whether
  slicing was intended is flagged for follow-up); the postgres savers
  claimed to take parse_report_file's return value but receive the
  inner report dict; elastic/opensearch save functions' Raises listed
  only AlreadySaved.
- None-as-semantic-state documented where missing (get_base_domain,
  get_ip_address_country), enumeration completeness fixed
  (get_ip_address_info's 9 result keys, maps script outputs, TSV
  columns), and the stale 44-industry-types count corrected to the
  46 the authoritative README list defines.
- Test docstrings aligned with what the tests actually assert,
  including two that overstated coverage of the elastic/opensearch
  address-list tests.
- Two argparse help strings fixed: file_path now names SMTP TLS report
  files alongside aggregate and failure, mirrored into usage.md's
  CLI-help block; --offline's doubled spaces removed (rendered help
  unchanged).
- elasticsearch.md's security claim corrected against Elastic's docs:
  security is enabled and auto-configured on first startup since 8.0
  (not "8.7 secure mode"), so the settings are verified, not
  hand-written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-08-28 17:33:18 -04:00
Sean WhalenandClaude Fable 5 52be8850b2 10.5.0 release: bump version and finalize changelog (#887)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:16:48 -04:00
Sean WhalenandClaude Fable 5 cd864f4cfc Add DNS over HTTPS and DNS over TLS support via the nameservers option (#886)
Each entry in the existing nameservers option now selects its own
transport (#880): an IP address means plain DNS on port 53 exactly as
before, an https:// URL means DoH, and tls://ip[:port][#hostname] means
DoT, with the optional #hostname naming the TLS certificate identity
(systemd-resolved syntax). Forms can be mixed in one list, and no new
configuration option is involved.

DoH queries go through a shared per-process httpx client passed to
dns.query.https as session=, which is what makes them honor HTTP_PROXY/
HTTPS_PROXY/NO_PROXY and SSL_CERT_FILE — the motivating proxy-only
corporate network case. dnspython's stock DoH path cannot do this: it
builds its httpx client around a custom transport, and httpx only reads
proxy environment variables when no transport is supplied
(allow_env_proxies = trust_env and transport is None). The client is
rebuilt when the PID changes so fork-based worker pools never share a
parent's sockets.

The dnspython requirement becomes dnspython[doh]>=2.7.0 — the extra
supplies the httpx/h2 floors DoH needs, and 2.7.0 is the floor verified
against the dns.nameserver and dns.query.https(session=...) APIs used.

The startup DNS pre-flight check now exercises whichever transports are
configured, so a malformed DoH/DoT entry raises ConfigurationError
before any mailbox work begins. Malformed tls:// entries — including
the plausible slash-for-# typo tls://9.9.9.9/dns.quad9.net, which would
otherwise silently drop the certificate identity — are rejected at
configuration time naming the entry.

Verified live: DoH A/PTR queries against Cloudflare and DoT against
Quad9 (tls://9.9.9.9#dns.quad9.net), plus a full CLI run over a sample
report with encrypted-DNS-only nameservers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:03:51 -04:00
Sean WhalenandClaude Fable 5 758d1ffe49 Decode failure report MIME parts per their Content-Transfer-Encoding (#885)
* Decode failure report MIME parts per their Content-Transfer-Encoding

Fixes #882.

parse_report_email() read every MIME part's payload without asking the
standard library to decode it, leaving any transfer encoding in place:

- A quoted-printable text/rfc822-headers sample part kept its RFC 2045
  §6.7 soft line breaks, which split long headers without RFC 5322
  folding whitespace. The sample's From header became unparseable and,
  with no Reported-Domain field in the report, the whole failure report
  was discarded with "TypeError: 'NoneType' object is not subscriptable".
- A quoted-printable message/feedback-report part parsed "successfully"
  with silently corrupted values (e.g. "dmarc=3Dfail").

A new _decode_mime_payload() helper decodes quoted-printable and base64
parts only, applied to the message/feedback-report and sample branches;
all other branches still receive the raw payload because they do their
own base64/magic-byte handling. Parts with a 7bit/8bit/absent CTE are
returned as-is: the message is parsed from a str, so compat32's
get_payload(decode=True) would round-trip the already-correct text
through raw-unicode-escape and corrupt non-ASCII characters. For nested
message/* parts (the stdlib nests every message/* subtype, so
decode=True returns None), the encoding is undone by hand, including
removing the header/body separator the Generator inserts when the
still-encoded text stops looking like headers mid-block
(MissingHeaderBodySeparatorDefect) — without that, values were truncated
at the first soft line break.

Also fixed in the process, per the same-PR rule for bugs found while
writing tests:

- feedback_report_regex captured the CR of CRLF line endings (RFC 5322
  §2.1 mandates CRLF; re.MULTILINE's "$" matches before the LF, not the
  CR). Previously masked because the base64 branch decoded through a
  bytes repr and stripped literal "\r" escapes.
- A report with no Reported-Domain field and no parseable sample From
  domain now raises InvalidFailureReport with a clear message instead of
  the opaque TypeError; reported_domain is a required str in the
  FailureReport contract (types.py) consumed unconditionally by the
  Elasticsearch/OpenSearch outputs, so defaulting it to None is not an
  option. InvalidFailureReport raised inside parse_failure_report() now
  propagates without the "Unexpected error:" re-wrap.

CLI output over the whole sample corpus is byte-identical before and
after (PYTHONHASHSEED=0, n_procs=1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use RFC 5322 header folding instead of implicit string concatenation

The hand-built test message's long Content-Type header was split across
two adjacent string literals inside a list, which reads like a missing
comma (flagged by code review). Fold the header with a tab continuation
line instead — truer to the wire format the builder exists to produce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reword test builder docstring to claim only what it does

Copilot review: the builder joins lines with "\n" and embeds CRLF inside
the feedback-report block, so it does not preserve on-the-wire bytes
exactly. What matters for the test is only that the non-ASCII sample
text stays unencoded; say that instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:14:07 -04:00
Sean WhalenandClaude Fable 5 8e200181e7 10.4.3 release: bump mailsuite floor to >=2.3.1 (#876)
mailsuite 2.3.1 fixes mailsuite.utils.parse_email() raising TypeError
instead of ValueError("Not an email") on unparseable (non-email) input
under mail-parser 4.6.2 (seanthegeek/mailsuite#61), which parsedmarc's
floors have required since 10.4.2.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:55:51 -04:00
Sean WhalenandClaude Fable 5 2352603eda 10.4.2 release (#875)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:47:26 -04:00
Sean WhalenandClaude Fable 5 400f3d319c Automate releases and docs deployment (#874)
Port mailsuite's tag-triggered release pipeline:

- Add release.yml: pushing a version tag runs the full CI suite
  (python-tests.yml via workflow_call), then builds the package (the tag
  must match the version in parsedmarc/constants.py, checked with
  `hatch version`), publishes to PyPI via Trusted Publishing, creates
  the GitHub Release with notes from the tag's CHANGELOG.md section and
  the built distributions attached, pushes the multi-arch Docker image,
  and deploys the Sphinx docs
- Add docs.yml: reusable docs build/deploy to GitHub Pages, also
  runnable on demand (workflow_dispatch) for documentation-only changes
  between releases
- docker.yml: add a workflow_call trigger with a push_image input, since
  a GitHub Release created with the workflow's own GITHUB_TOKEN emits no
  `release: published` event; release.yml calls it directly instead
- Remove the legacy build.sh / publish-docs.sh manual process
- AGENTS.md: CRITICAL rule that releases require explicit maintainer
  permission, plus docs for the new release flow and its one-time
  repo/PyPI configuration prerequisites
- Bump the mailsuite floor to >=2.3.0 (raises the transitive mail-parser
  floor to >=4.6.2 and cryptography to >=50.0.0)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:22:12 -04:00
Sean WhalenandClaude Opus 5 b304cf8639 Cover index_prefix_domain_map and index_suffix in index migrations (#868) (#869)
* Cover index_prefix_domain_map and index_suffix in index migrations (#868)

The startup Elasticsearch/OpenSearch index migrations built their target
index names from the [elasticsearch]/[opensearch] index_prefix and
index_suffix options alone, while the save path also honors
general.index_prefix_domain_map. A multi-tenant deployment therefore ran
the backfill guard `count` against dmarc_aggregate*/smtp_tls*, patterns
matching none of its real <tenant>_dmarc_aggregate-* indexes. The query
passes allow_no_indices=True, so a zero-match wildcard returns count 0 --
indistinguishable from "already backfilled" -- and the backfill was
skipped silently, with no log line at any level.

Resolve migration index names through a new _migration_index_names()
helper that widens both configurable axes: one name per tenant prefix in
the map plus the unprefixed name (unmapped domains are still saved
unprefixed), and, when an index_suffix is set, the unsuffixed name
alongside the suffixed one so history predating the suffix is covered. A
configured index_prefix still wins outright and suppresses the map
fan-out, matching save-time precedence. The key normalization is now
shared with get_index_prefix() via _normalize_index_prefix(), so the
names parsedmarc migrates cannot drift from the ones it writes. The
resolved lists are logged at DEBUG, and the SIGHUP reload path passes the
freshly parsed map, so a newly onboarded tenant is covered without a
restart.

Also repair the legacy published_policy.fo migration, which has been
unable to complete since mapping types were removed in Elasticsearch 7
(and never existed in OpenSearch): it read the field mapping in the
type-keyed response shape, so the check always fell through, and its
put_mapping() call passed a doc_type argument neither current client
accepts. It now reads either response shape, uses each client's current
signature, and takes its index names in a separate legacy_fo_indexes
argument -- exact names, since 5.0.0 introduced date-suffixed index names
in the same release that fixed the fo declaration, but prefixed and
suffixed where configured, since both options date back to 4.1.0. Tenant
prefixes are excluded: index_prefix_domain_map arrived in 8.19.0, and
this migration renames the index it rebuilds. The Elasticsearch copy,
removed as unreachable during the #806 client migration, is restored now
that the cause is understood.

Finally, reject an index_prefix_domain_map YAML file that is not a
mapping of string tenant names to lists of domains, instead of raising
mid-save on a non-string key or silently matching the wrong domains on a
scalar value -- `in` on a str is a substring test, so "example.co"
matches "example.com"
(https://docs.python.org/3/reference/expressions.html#membership-test-operations).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix OpenSearch capitalization and spacing in the usage docs

Copilot review of #869: the `index_prefix_domain_map` option line spelled
"OpenSearch" as "Opensearch" and had a doubled space before the type. Both
predate this branch but sit inside a hunk it rewrites. Also wrapped the line
to match the continuation-indent style of every other option in the list, and
corrected the same misspelling in the multi-tenant section a few lines above
the paragraph this branch added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Require index_prefix_domain_map domain lists to hold strings

Copilot review of #869: the shape check verified that each value was a
list but not what the list contained, so `tenant_a: [42]` passed and then
never compared equal to any domain the save path looks up -- the same
silent-misbehavior class the check exists to reject, and a contradiction
of the "any other shape is rejected at startup" claim in the docs.

Check the list's items too, and reword the error message, comment,
CHANGELOG and docs to state the rule the check now enforces: a mapping of
tenant names to lists of domain names, all strings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Make the legacy fo migration retry-safe, and nest its mapping body

Copilot review of #869, verified against live Elasticsearch 8.19 and
OpenSearch 3 containers rather than mocks.

Retry safety (a real defect): an attempt interrupted between creating the
-v2 index and deleting the original left debris that made create() fail
with "resource already exists" on every later startup, inside the same try
that swallows the error -- so the index was never migrated and the debris
document survived. Reproduced on a live cluster. Discard a leftover target
first; that is safe precisely because reaching this point means the
original still holds the data, since it is deleted only once the reindex
has succeeded.

Mapping body: the reviewer's concern that a dotted key under `properties`
risks a runtime failure does not hold -- both clusters accept it and
produce a byte-identical mapping, with the dot expanded into
published_policy -> properties -> fo. Switch to the nested object form
anyway, since dot expansion is conditional on the object's `subobjects`
setting and this shape never is, and derive the object/leaf names from the
dotted constant so the write cannot drift from the field the read looks up.

Both fo-migration suites now build per-name Index mocks. A single shared
mock cannot express "the original exists but its migration target does
not", which is the ordinary case and the one the retry fix turns on; the
happy path now also asserts the target index is never deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:48:00 -04:00
Sean Whalen 483ae8112d 10.4.0 release 2026-07-26 18:16:30 -04:00
Sean WhalenandClaude Fable 5 a5a5b45a50 Unify DMARC pass/fail chart naming on "DMARC compliance" (#865)
* Unify DMARC pass/fail chart naming on "DMARC compliance"

The same metric had three names across the dashboards: the pass/fail
pie chart was "Passed DMARC" (OpenSearch, Splunk) and "DMARC Passage"
(Grafana), while the per-domain table column added in #834 was
"% DMARC Compliant". Converge on the compliance wording, which already
anchored the "Message volume and DMARC compliance by from domain"
panels in every dashboard family:

- Pie chart: "DMARC compliance" ("DMARC Compliance" in Grafana, which
  title-cases panel names; OpenSearch and Splunk use sentence case).
- Line chart: "DMARC passage over time" -> "DMARC compliance over
  time" everywhere, so the pie rename doesn't leave the same
  inconsistency one panel down.
- Splunk filter dropdown label: "Passed DMARC" -> "DMARC compliant".
- Grafana Guide panel text and docs/source/kibana.md prose updated to
  match, plus a pre-existing typo fix ("pie charts. you").

Field names, tokens, and queries (passed_dmarc / dmarc_passed) are
untouched; only human-facing titles, labels, and prose changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix "pie charts. you" typo in the Grafana Guide panel text

The period-for-comma typo fixed in docs/source/kibana.md also existed
in the Grafana Guide panel markdown, in both duplicated copies of the
guide text (content and options.content). Caught by Copilot review on
PR #865.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sync the Grafana Guide panel walkthrough with the current panels

The Guide text described a three-column table layout with a from-domain
list "on the right" and directed readers to a "Message From Header"
table, but the dashboard has two table columns (Reporting Organisations
on the left; Top 2000 Message Sources by Reverse DNS on the right with
Message volume and DMARC compliance by from domain below it) and no
Message From Header table. Point the walkthrough at the real panel
titles and mention the per-domain compliance percentage the table
shows, mirroring the kibana.md walkthrough updated in #834. Both
duplicated copies of the guide markdown (content and options.content)
remain identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope the CHANGELOG rename claim to dashboards that have the charts

The bullet said the pass/fail charts were renamed "across the ...
Grafana dashboards", but the PostgreSQL Grafana variant has no
pass/fail pie or over-time chart, so the claim quantified over a
dashboard it doesn't apply to. Name the affected dashboards explicitly
and note the PostgreSQL variant is unchanged. Caught by Copilot review
on PR #865.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:13:06 -04:00
Sean WhalenandClaude Fable 5 35e51218b9 Don't archive/delete mailbox messages until output destinations confirm the save (#863)
* Don't archive/delete mailbox messages until output destinations confirm the save (#242)

get_dmarc_reports_from_mailbox() gains a keyword-only save_callback,
invoked once per fetched batch before any message is deleted or moved.
A False return (or an exception) marks the batch unsaved: its messages
stay in the reports folder for retry, and the aggregate-report dedup
keys staged for that batch are dropped so the retry reparses instead of
skipping. watch_inbox() passes its callback through as save_callback,
so watch mode gets the same protection.

To bound duplicate delivery to destinations that don't deduplicate, a
new [mailbox] max_unsaved_retries option (default 2) caps retries: a
message whose batch has failed the initial attempt plus that many
retries moves to {archive_folder}/Unsaved -- never deleted, whatever
the delete options say. Counts are process-local, so the cap applies
across watch-mode checks; one-shot runs retry indefinitely, which is
the safe direction. A raising callback (the CLI's, under
fail_on_output_error) is counted against the cap the same way before
the exception is re-raised, since mailsuite's IMAP and Maildir watch
loops swallow exceptions and keep checking.

CLI: process_reports() now returns its output-error list and the
mailbox_save_callback adapter feeds that verdict to the library;
save_output() failures (the one uncaught destination) are recorded like
every other sink's; file/mbox-derived reports are saved in a separate
pass from mailbox batches so nothing is saved twice; the combined
results fed to email_results() are filtered by index_prefix_domain_map
explicitly, restoring the SMTP TLS filtering the emailed summary lost
when saving moved into per-batch callbacks.

Credit to @mkilijanek for the original approach in #823.

Fixes #242

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address automated review feedback on #863

- Keep a message's failure counter until its move to Unsaved actually
  succeeds. The counter was popped when the message was classified
  over-cap, before the move was attempted, so a failed move handed the
  still-in-place message a fresh set of under-cap retries and duplicate
  deliveries; now the next failed save classifies it over-cap again and
  re-attempts the move. Regression-tested at cap 1, where the reset
  would observably leave the message in the INBOX instead of moving it.
- Use sys.exit(1) instead of the site-dependent exit() built-in in the
  two new ParserError handlers.
- Close the sample files opened by the new Maildir tests via context
  managers.
- Keep the docs' section-link text on one line and fix the adjacent
  pre-existing "a IMAP" typo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot re-review feedback on #863

- Document mailbox_save_callback's raise path: with fail_on_output_error
  it raises ParserError via process_reports() instead of returning
  False, and the library counts that as an unsaved batch too.
- Use str(error_) instead of error_.__str__() in the new File output
  handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reject negative max_unsaved_retries with a ValueError

The option is user-configurable (INI/env/kwarg); a negative value
silently behaved like 0. Both get_dmarc_reports_from_mailbox() and
watch_inbox() now validate it at the door alongside the existing
test/delete guard. watch_inbox() validates before entering the watch
loop, because a ValueError raised inside a check would be swallowed
and endlessly retried by the IMAP and Maildir backends' per-check
exception handling instead of surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix two pre-existing bugs surfaced by review of #863

- MAGIC_JSON was b"\7b" -- the octal escape \7 (BEL) plus a literal
  "b", not 0x7B, the "{" every RFC 8259 JSON object begins with -- so
  extract_report() rejected plain uncompressed JSON. Every in-tree
  caller pre-guarded with its own zip/gzip or "{" check, masking the
  dead branch; the practical impact was extract_report() as a public
  API and application/tlsrpt+gzip attachments whose payload is really
  uncompressed JSON. Now b"\x7b".

- get_index_prefix() unconditionally indexed policies[0], while
  parse_smtp_tls_report_json() accepts a report whose policies list is
  empty -- an IndexError crash whenever index_prefix_domain_map was
  configured. An empty-policies report has no domain to map, so it is
  now treated as unmappable and excluded from prefix-mapped output.
  This code was moved into filter_smtp_tls_reports_for_index_prefix()
  by this PR, which widened its exposure to the email_results() path.

Both regression tests were verified to fail against the unfixed code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rewrap the max_unsaved_retries docstring paragraph

The ValueError sentence was inserted without reflowing, leaving a
112-character line in a docstring wrapped at ~76; ruff formats code,
not docstring prose, so it slipped through lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:07:03 -04:00
Sean WhalenandClaude Fable 5 ae5f4c7915 Add per-report-type mailbox delete options (#858)
* Add per-report-type mailbox delete options (#256)

Add four new [mailbox] options — delete_aggregate, delete_failure,
delete_smtp_tls, and delete_invalid — each defaulting to the value of
the overall delete option and individually overridable, so e.g.
delete = True with delete_failure = False deletes processed aggregate
and SMTP TLS report messages while archiving failure reports, and
delete_invalid = False keeps unparseable messages in the Invalid
archive subfolder for debugging.

get_dmarc_reports_from_mailbox() and watch_inbox() gained matching
bool | None keyword arguments (None = inherit from delete), resolved
once up front; the delete/test mutual-exclusion guard now checks the
effective per-type flags. The Gmail deletion-scope guard covers any
effective flag and forces all five options off when the scope is
missing. PARSEDMARC_MAILBOX_DELETE_* env vars work automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document per-type delete kwargs as bool | None with explicit inheritance

Copilot review: the docstrings typed the four per-report-type delete
parameters as plain bool, but None (the default) is the inheritance
mechanism — a library caller couldn't tell from the docs that None,
not False, means "inherit delete".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover disposal error branches; scope the config= docstring claim

Codecov flagged the delete-error handler in the new per-type disposal
loop as the one uncovered patch line. Add two Maildir tests driving a
backend whose first delete/move call raises: the error is logged, the
affected message stays in the INBOX, and disposal continues to the
next report type. The move-error branch gets the symmetric test from
the same harness.

Copilot review: the config= docstring paragraph claimed all keyword
arguments listed above it are ignored when config= is provided, which
now falsely included the four per-type delete options. Scope the claim
to the parsing/enrichment arguments ParserConfig actually carries and
state that mailbox-handling arguments always apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 13:31:37 -04:00
Sean WhalenandClaude Fable 5 264b9a4556 Add [general] archive_directory to archive processed local files (#570) (#856)
* Ignore Claude Code agent worktrees under .claude/worktrees/

Untracked repo snapshots from agent sessions were making repo-root
ruff check . fail on stale code and cluttering git status. ruff
respects .gitignore, so ignoring the directory fixes both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add [general] archive_directory to archive processed local files (#570)

Move successfully processed report files given as local path arguments
into <archive_directory>/<year>/<month>/<Aggregate|Failure|SMTP-TLS>/,
dated from the parsed report's own metadata (aggregate begin_date,
failure arrival_date_utc, SMTP TLS begin_date) rather than the
filename. Files that fail to parse as a report (ParserError) go to
<archive_directory>/Invalid/; other failures (e.g. transient I/O
errors) leave the file in place so a later run can retry it.

Existing destination files are never overwritten: each candidate name
is claimed atomically (O_CREAT | O_EXCL) and collisions get a numeric
suffix before the extension. Files already inside the archive
directory are excluded from processing (compared via realpath so
symlinked spellings still match), so the archive can safely live
inside an input directory, as the issue requests. mbox files and
mailbox modes are unaffected; mailbox modes keep [mailbox]
archive_folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review feedback on docstrings and the placeholder-cleanup except

- _move_file_to_archive's docstring no longer claims the copy2 fallback
  replaces the placeholder atomically; only the same-filesystem os.rename
  path is atomic. The fallback is a plain copy-and-overwrite, which is
  still collision-safe because the placeholder already claimed the name.
- The empty except OSError in the placeholder cleanup now carries a
  comment explaining that it is deliberate best-effort cleanup and the
  re-raised move failure is the actionable error.
- test_general_archive_directory_unset_leaves_attribute_absent's
  docstring now describes what the assertion actually tests (the
  attribute staying absent from a bare Namespace) and moves the
  real-CLI None-default behavior to a parenthetical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover the two defensive exception branches Codecov flagged

Codecov's patch report flagged four uncovered lines, all in the two
platform-dependent exception branches of the archive helpers:

- _exclude_archived_paths's except ValueError branch. Per the Python
  docs for os.path.commonpath, ValueError is raised when paths "are on
  the different drives" (Windows) or mix absolute and relative
  pathnames; both inputs are realpath()-resolved so only the
  different-drives case remains, which Linux CI can't produce
  naturally. The new test simulates the raise and asserts the
  non-comparable path is kept for parsing rather than excluded.

- _move_file_to_archive's except OSError placeholder-cleanup branch.
  The new test fails the move with a non-OSError type and the cleanup
  with OSError, then asserts the move error is what propagates (the
  cleanup error is swallowed, not allowed to mask it) and that the
  zero-byte placeholder survives its failed cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tag arrival_date_utc as UTC when parsing the archive date

human_timestamp_to_datetime's docstring names arrival_date_utc as
exactly the kind of known-UTC naive string that should be parsed with
assume_utc=True; _archive_subdir_for_result was parsing it naive.

The flag is scoped to the failure branch because the shared call also
handles the other two report types: aggregate begin_date is a
local-time wall-clock string (timestamp_to_human uses
datetime.fromtimestamp), so tagging it UTC would be wrong, and SMTP TLS
begin_date carries an RFC 3339 offset, making assume_utc a no-op.

No observable behavior change: only the wall-clock year/month fields
are read and assume_utc never shifts wall-clock time, so no new test
can honestly distinguish the two versions — this aligns the call with
its dependency's documented contract and hardens against a future edit
adding a real timezone conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Create the archive placeholder with mode 0o600

os.open's mode parameter defaults to 0o777 (masked by the umask), so
the O_CREAT|O_EXCL placeholder in _move_file_to_archive was created
executable and group-accessible on typical umasks (0o775 under umask
002). Normally it's replaced immediately, but a placeholder that
outlives a failed move+cleanup persisted with those permissions.

Pass 0o600 explicitly. The mode never reaches the real archived file:
os.rename replaces the placeholder's inode outright, and the copy2
fallback's copystat overwrites the mode with the source file's. The
leftover-placeholder regression test now also asserts the surviving
placeholder has no owner-exec or group/other bits (umask-independent,
since the umask only clears bits); the assertion fails against the
unfixed default-mode call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:56:01 -04:00
Sean WhalenandClaude Fable 5 a421747a79 Add a Domain policy dropdown filter to the Splunk aggregate dashboard (#854) (#855)
Adds a dropdown that filters every panel on the published DMARC policy
(published_policy.p) or subdomain policy (published_policy.sp), mirroring
the Message disposition dropdown's choices and default and sitting
immediately before it, per the issue spec. Both fields are always present
on parsedmarc-written events (sp defaults to p in the parser), so the
wildcard default matches everything, consistent with the other raw-search
filters.

Also fixes the Splunk docs page's XML-files link, which still pointed at
the pre-#736 splunk/ path and 404ed.

Closes #854

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 20:48:44 -04:00
Sean WhalenandClaude Fable 5 4e80047e68 Centralize configuration handling with ParserConfig (#503) (#851)
* Centralize configuration handling with ParserConfig (#503)

Add parsedmarc/config.py with ParserConfig, a frozen dataclass carrying
every parsing/enrichment option plus the three shared caches (IP address
info, seen aggregate report IDs, reverse DNS map). All eight public
parsing/mailbox functions accept a keyword-only config= argument; when
provided, the individual option keyword arguments are ignored in favor
of the config's values, and every existing per-option keyword argument
keeps working unchanged. The three hand-copied parse_kwargs dicts and
the dns_timeout<->timeout rename chain are gone; the CLI builds one
ParserConfig per run (rebuilt on SIGHUP) and passes it everywhere.

Explicitly constructed configs own fresh isolated caches;
dataclasses.replace() shares them; pickling drops cache contents and
rebinds the unpickling process's module defaults, preserving the
per-worker cache behavior of n_procs parallel parsing. The module
globals IP_ADDRESS_CACHE / SEEN_AGGREGATE_REPORT_IDS / REVERSE_DNS_MAP
remain, identity-preserved, as re-exports of the default caches.

Bug fixes that ride along, each with a regression test:

- One-shot mailbox runs now honor [general] dns_timeout/dns_retries;
  the CLI call site never forwarded dns_timeout, so the library's
  stray 6.0 default silently applied.
- Lazily-triggered reverse DNS map loads (get_ip_address_info /
  get_service_from_reverse_dns_base_domain, including in n_procs
  workers) now thread psl_overrides_path/psl_overrides_url through to
  load_reverse_dns_map instead of clobbering operator-configured PSL
  overrides with the bundled defaults.
- get_dmarc_reports_from_mailbox() and watch_inbox() dns_timeout
  defaults unified to DEFAULT_DNS_TIMEOUT (2.0s) from a stray 6.0, and
  normalize_timespan_threshold_hours to the float 24.0 used everywhere
  else.

Closes #503

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address CI and review feedback on #851

- Wrap the IMAPConnection example in usage.md so ruff format is clean
  over the docs code blocks (CI runs ruff format --check on the whole
  repo; the local runs were scoped to parsedmarc/ and tests/ and
  missed it).
- Fix the pre-existing "URL ro a reverse DNS map" docstring typo in
  get_service_from_reverse_dns_base_domain, caught by Copilot on the
  adjacent hunk.
- Import parsedmarc.config once, as an aliased plain import, in
  tests/test_config.py instead of mixing import and import-from of the
  same module (flagged by code quality scanning); the aliased module
  import also keeps pyright able to resolve the submodule attribute
  access.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second Copilot review round on #851

- Add parse_aggregate_report_file() to the library entry-point list in
  usage.md; the following paragraph describes the config= contract for
  "each of these functions", so the list must name all eight
  config-accepting entry points.
- ParserConfig.__setstate__ now initializes every non-cache field to
  its class default before applying the pickled state, so a config
  serialized by an older parsedmarc version (whose state predates
  fields added later) unpickles with the newer fields at their
  defaults instead of unset entirely (__init__ never runs during
  unpickling, so an absent field would raise AttributeError on first
  access). Covered by a regression test that feeds __setstate__ a
  partial state dict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:27:09 -04:00
Sean WhalenandClaude Fable 5 48445c639e Extend n_procs parallel parsing to mbox and mailbox sources (#147) (#849)
* Extend n_procs parallel parsing to mbox and mailbox sources (#147)

n_procs previously applied only to report files passed directly as CLI
arguments; messages from mbox files and mailbox connections (IMAP,
Microsoft Graph, Gmail API, Maildir) were always parsed sequentially.

A new parsedmarc.parallel module provides a shared bounded-window
ProcessPoolExecutor helper (parallel_map) used by all three input paths.
Only parsing fans out to a reused worker pool; message fetching, report
deduplication, mailbox archiving/deletion, and output stay sequential in
the main process. The submission window keeps at most ~2*n_procs
messages in flight, so memory stays bounded even for huge mboxes, and
the mailbox path fetches messages lazily on the connection-owning main
thread. keep_alive never crosses the process boundary - the main
process sends periodic IMAP keepalives while workers parse - and with
n_procs > 1, invalid-message disposition happens after the parse phase,
mirroring the existing deferred bulk archive moves.

get_dmarc_reports_from_mbox, get_dmarc_reports_from_mailbox (including
its tail-recursive re-check), and watch_inbox gain an n_procs keyword
argument (default 1); sequential behavior at the default is unchanged.

Replacing the CLI's hand-rolled Pipe/Process batching also fixes two
defects in the direct-file path: a child process that died from a
non-ParserError exception left the parent blocked forever on
conn.recv(), and the hard batch barrier let one slow file idle every
other worker slot. Workers are now a reused pool (no fresh interpreter
per file), with worker logging reconstructed via a spawn-safe pool
initializer instead of fork inheritance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot and code-quality review feedback on #849

- parallel_map now validates n_procs >= 1 itself with a clear error
  instead of surfacing ProcessPoolExecutor's max_workers error later.
  The check raises eagerly at the call (the generator body moved into an
  inner function) rather than on first iteration, with a regression test.
- Aligned parallel_map's should_stop docstring with the implementation:
  queued-but-unstarted jobs are cancelled, while in-flight jobs are
  waited on and their results yielded, so the stop can block briefly but
  never discards completed work.
- The parallel mailbox path keeps fetched message ids in a deque popped
  as each in-order result arrives, so the id queue stays bounded by the
  submission window instead of growing to message_limit.
- Closed the three sample-file handles the new tests opened without a
  context manager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second round of Copilot feedback on #849

- configure_logging no longer stacks duplicate FileHandlers when called
  again with the same log_file (compared by FileHandler.baseFilename,
  which stores the absolute path): a duplicate wrote every record twice
  and leaked a file descriptor per call, e.g. across SIGHUP config
  reloads. Latent in the pre-extraction cli._configure_logging too.
  Regression tests in the new tests/test_log.py.
- Renamed the CHANGELOG's premature "10.4.0" heading to "Unreleased",
  matching the project convention where in-progress entries accumulate
  under Unreleased and the release PR renames the section and bumps
  parsedmarc/constants.py together (as in the 10.3.0 release).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:14:40 -04:00
Sean WhalenandClaude Fable 5 c95d4666dc Release 10.3.0 (#848)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:51:20 -04:00
Sean WhalenandClaude Fable 5 0a95a0ceb2 Upgrade ruff to 0.16.0 and pyright to 1.1.411; convert to f-strings (#847)
* Upgrade ruff to 0.16.0 and pyright to 1.1.411; convert to f-strings

ruff 0.16.0 expanded the default lint rule selection well beyond the
long-standing E4/E7/E9/F, so [tool.ruff.lint] now selects the rule set
explicitly: the pre-0.16 defaults plus the modern-type-hint UP rules and
the two f-string rules (UP030/UP032). All 352 UP030/UP032 findings were
auto-fixed; conversions requiring Python 3.12 f-string quote reuse were
conservatively left as .format() by ruff (verified: the whole package and
test suite byte-compile under CPython 3.10.20, the oldest CI version).
Adopting the other newly-default rule families (BLE, SIM, C4, DTZ, I, ...)
is deferred as a deliberate per-family decision.

ruff format with 0.16.0 also now formats Python code fences in Markdown,
which reformatted one block in parsedmarc/resources/maps/AGENTS.md.

ruff check, ruff format --check, pyright (0 errors/warnings), and the
full test suite (775 passed) are green on the new versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot review findings on the f-string conversion

- Rewrite messages that used backslash line continuations inside string
  literals, which embedded the source indentation as literal whitespace
  in the logged/raised text: the duplicate search-error messages in the
  Elasticsearch and OpenSearch outputs, the 'since'-option warning (which
  also implicitly concatenated "24hrs" and "SMTP" with no separator) and
  the IMAP 'since' debug line, and the missing-org_name KeyError message.
- Build the Splunk HEC newline-delimited payloads by appending to a list
  and joining once instead of quadratic string concatenation in a loop.

Declined: switching the webhook output's logger.error to
logger.exception — the single-line ERROR without a traceback is the
deliberate house pattern for batch-resilient sinks, and changing log
verbosity is out of scope for this refactor PR.

ruff check/format, pyright (0 errors/warnings), 775 tests, and a
CPython 3.10 compileall all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:46:36 -04:00
Sean WhalenandClaude Fable 5 df086e7242 Fix mbox-run progress display and document n_procs scope (#147) (#846)
* Fix mbox-run progress display and document n_procs scope (#147)

An mbox-only run showed a misleading, permanently-stuck `0it` progress
bar: the CLI's tqdm bar only tracks report files passed directly as
arguments (mbox paths are split out first), and per-message mbox
progress is only logged at INFO, which --silent / config-file runs
hide. The empty bar is no longer created when there are no direct file
arguments, and get_dmarc_reports_from_mbox() now wraps its message loop
in a tqdm bar that auto-disables on non-TTY output (tqdm's
disable=None), so interactive mbox imports show real per-message
progress while tests, cron jobs, and piped runs stay clean.

Also documents that n_procs parallel parsing applies only to report
files passed directly on the command line — messages from mbox files
and mailbox connections (IMAP, Microsoft Graph, Gmail API, Maildir)
are always processed sequentially — and fixes the pre-existing
"Number of process" typo in that entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Gate the CLI progress bar on stderr, where tqdm renders it

Addresses Copilot review feedback on #846: tqdm writes to stderr by
default, but the guard checked sys.stdout.isatty(), so a run with
stderr redirected to a log file would write bar escapes into the log,
and a run with stdout redirected (e.g. piping the JSON output) hid the
bar even though stderr could display it. This also matches the mbox
bar's disable=None auto-check, which keys off tqdm's own output stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:11:54 -04:00
Sean WhalenandClaude Fable 5 23c5ea9ad6 Fix DKIM/SPF and SMTP TLS detail-table cross-products in dashboards (#169) (#839)
* Fix DKIM/SPF alignment detail cross-product in dashboards (#169)

Elasticsearch and OpenSearch dynamic-map the dkim_results/spf_results
object arrays as `object` (create_indexes never registers the DSL
document mappings), so Lucene flattens each array into independent
multi-valued fields and stacked terms aggregations on
dkim_results.selector/.domain/.result return every combination of
values across a report's signatures — each phantom row repeating the
full message count.

Aggregate documents now also carry dkim_results_combined and
spf_results_combined: one "selector / domain / result"
("scope / domain / result") string per auth result, composed in
add_dkim_result/add_spf_result. The Kibana/OpenSearch Dashboards and
Grafana (Elasticsearch) alignment-detail tables aggregate those
instead, and the Splunk detail panels pair the values with
mvzip/mvexpand. A documented idempotent _update_by_query backfills
documents saved by older versions; the query matches only documents
that have auth results and lack the combined fields, because an
`exists` query cannot see an empty array.

Also corrects the dead _SPFResult.results (plural) declaration to
`result` (the save path always wrote the singular key), fixes the
result parameter annotations on add_dkim_result/add_spf_result, and
removes the Grafana dmarcian.com DKIM-checker data link, which
required the separate domain/selector columns.

The SMTP TLS visualizations have the same class of defect and are
tracked separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot review findings on #839

Reword the combined-field regression test docstrings: the DKIM/SPF
auth results are dynamic-mapped as plain `object`, not the `nested`
mapping type the previous wording implied — the distinction is the
crux of the fix. Also drop the inert renameByName entries Copilot
flagged on the Grafana Overview and DKIM Alignment Details panels,
which referenced fields those panels' queries no longer (or never)
produced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add PR #839 review lessons to AGENTS.md

Extend the "Review passes cover prose" section with two rules from
the #839 Copilot findings: docstrings/comments get the same
text-level review pass as docs and dashboard labels (with suspicion
for dual-use terms like "nested" near Elasticsearch code), and
inert config entries inside hunks a PR already rewrites should be
cleaned rather than preserved to minimize the diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reflow create_indexes comments flagged by Copilot

The line wrap placed "#169" directly after the comment marker, so the
raw source read "# #169;". Reword so the issue reference stays on one
line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extend the hunk-proofreading rule with rendered-text wraps

Fold the PR #839 second-round Copilot lesson into the existing rule:
proofread how wrapped lines render (comment markers, punctuation at
wrap points), not just the wording itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename Overview combined-result column labels (Copilot round 3)

The Overview table's "DKIM Auth Result" / "SPF Auth Result" labels
were kept when the columns switched to the combined
"selector / domain / result" values, leaving the headers misleading.
Rename them to match the detail panels' convention and retarget the
byName width overrides that matched the old labels, widening them for
the longer values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document per-signature row semantics in the alignment tables

A message carrying multiple DKIM signatures appears once per signature
in the details tables, so summing the messages column across rows can
exceed the total message count. State that explicitly rather than
leaving readers to infer it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot round-4 findings on dashboards

Fix three pre-existing saved-object title typos in the OpenSearch
ndjson (leading space on "Aggregate DMARC passed DMARC", trailing
space on "Aggregate DMARC reporting organizations", double space in
"map  of message sources by country"), in both the top-level title
and the embedded visState title.

Normalize the Splunk DKIM details placeholders: the base search's
fillnull renders wholly-missing DKIM fields as the literal string
"null", so unsigned mail showed "null / null / null" while the SPF
panel shows "none". Rewrite the values to "none" after the signature
split, where the fields are single-valued and the mvzip pairing
cannot be disturbed. Verified against the dev Splunk that no
truncation or mis-pairing occurs either way, since fillnull
guarantees the fields are never actually null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Backfill combined DKIM/SPF fields automatically at startup

migrate_indexes() now backfills dkim_results_combined and
spf_results_combined on aggregate documents saved by older versions,
so ES/OS users get historical data in the reworked alignment tables
without running the documented _update_by_query by hand. The backfill
is submitted as a non-blocking background task
(wait_for_completion=false, conflicts=proceed) guarded by a cheap
count query, making repeated startups a fast no-op once an index is
backfilled; any cluster error is logged as a warning and retried at
the next startup rather than raised. The manual command remains
documented for users who upgrade dashboards without pointing the new
parsedmarc at the cluster or who want to control write-load timing.

The legacy published_policy.fo long-to-text reindex migration in the
OpenSearch module is kept ahead of the new backfill, for clusters
upgraded from very old data.

Verified end-to-end against the live dev environment: a real CLI
startup backfilled 9 stripped OpenSearch documents (logged with task
ID) while the already-backfilled Elasticsearch side stayed silent,
and a second startup was silent on both engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Match backfill guard on either domain or result subfield

End-to-end upgrade testing (real parsedmarc 10.2.4 ingest, then a
branch startup) surfaced that an exists query cannot see an empty
string: a text field with no tokens is invisible to exists. The
parsers we audited never store an auth result with an empty or
missing domain — they drop such entries entirely, so the previous
domain-only guard was sufficient for their data — but the storage
shape of every historical parsedmarc version can't be audited, so
the guard (and the documented manual command) now matches either
the domain or the result subfield per protocol. Matching either
costs nothing and cannot skip a document that has something to
backfill.

Verified by recomputing expected combined values from _source for
all 2,299 documents on both engines: every document with stored
auth results has exactly the recomputed pairs, and the legacy fo
migration correctly did not fire on typeless indexes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add auth-result filter controls to the Kibana/OSD aggregate dashboard

The combined per-signature columns fixed the #169 cross-product but left
no way to click-filter by an individual selector, domain, or result. Add
an "Aggregate DMARC auth result filters" input_control_vis panel above
the SPF/DKIM details tables with six option-list dropdowns (DKIM
selector/domain/result, SPF scope/domain/result) that emit ordinary
dashboard-wide filter pills. Works on both Kibana 8.19 and OpenSearch
Dashboards 3, verified by driving the controls in both UIs against the
issue's two-signature repro report.

Documented in kibana.md, including the flat-mapping caveat: combining
two component filters matches documents where any signature satisfies
each condition individually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scale Grafana source-country map markers with message volume

The "Map of Message Source Countries" panel drew fixed 5 px dark-green
markers at 50% opacity — nearly invisible on the dark basemap, so the
panel read as empty even when data was flowing (verified via the query
API). Markers now scale with Sum(message_count) (min 4, max 30 px) at
0.8 opacity in a higher-contrast green. Pre-existing issue; the
identically-styled failure-dashboard map panel is intentionally left
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Correct the nested-mapping rationale in the create_indexes comments

The comments claimed Kibana/OSD/Grafana "cannot terms-aggregate fields
inside a nested mapping" — too absolute. Fact-checked empirically and
against primary docs: Kibana/OSD visual editors (Lens and classic
Visualize) do not support nested fields, but Vega panels can run nested
aggregations (they just cannot render tables, per Elastic's docs), and
Grafana >= 9.4 has a nested bucket aggregation (grafana/grafana#62301)
but no reverse_nested, so parent-level metrics like Sum(message_count)
return 0 inside per-signature buckets (reproduced live). Conclusion
unchanged: the dynamic object mapping stays load-bearing for the
shipped dashboards. PR #839's body was updated with the same
correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin ruff exactly, matching the existing pyright pin rationale

CI installs the [build] extra fresh on every run, and ruff was the one
lint tool left unpinned. ruff 0.16.0 (released this week) began
flagging this codebase's str.format() house style, so every PR started
failing lint on lines it never touched. Pin to 0.15.21 — the version
the codebase is clean under — with the same bump-deliberately comment
pyright carries. Upgrading to 0.16 and converting to f-strings can be
its own PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot findings: harden migrate_indexes, normalize panel titles

Three unresolved review threads, all verified against cli.py's
re-raising init handler before fixing:

- elastic.py/opensearch.py: connections.get_connection() sat outside
  migrate_indexes()'s try/except, so a connection-registration failure
  would abort startup despite the docstring's promise that migration
  errors are caught and logged. Now caught, logged, and skipped until
  the next startup.
- opensearch.py: the legacy published_policy.fo migration loop did
  unguarded network I/O (exists/get_field_mapping/reindex/delete), so a
  transient cluster error aborted startup on the OpenSearch path while
  the identical situation on the Elasticsearch path was logged and
  survived. Each index's migration attempt is now wrapped, warns, and
  moves on.
- opensearch_dashboards.ndjson: normalized two pre-existing panel
  titles in the aggregate dashboard's panelsJSON ("Reporting
  organizations " trailing space, "Map  of message sources by country"
  double space).

Regression tests assert migrate_indexes never propagates connection or
per-index cluster errors on either backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clarify that Nested() on auth-result fields is in-memory shape only

Copilot flagged that _AggregateReportDoc declares dkim_results and
spf_results with Nested(...) while the create_indexes comment insists
the stored mapping must stay dynamic `object`. Both are true: the
Nested declaration only shapes the DSL's in-memory document building
and is never installed as a mapping, because create_indexes skips
Index.document() registration. Say so at both sites, in both backends,
so nobody "fixes" the mismatch by registering the mapping — which
would install real nested mappings and blank the dashboards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Refer to the filter panel by its displayed title in docs and CHANGELOG

The dashboard convention is a short panel display title backed by a
long-form saved-object name ("SPF details" / "Aggregate DMARC SPF
details"), and the new controls panel follows it. The docs and
CHANGELOG named the panel by its saved-object title, which is not what
a user sees on the dashboard; use the displayed "Auth result filters"
instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix misspelled column label in the failure email samples table

The "DMARC failure email samples" visualization labeled its
authentication_results column "autentication_results". The underlying
field reference was already correct; only the user-facing customLabel
was misspelled. A sweep of every title and customLabel in the ndjson
found no other misspellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extend the combined-field fix to SMTP TLS documents

SMTP TLS reports have the same cross-product defect as the DKIM/SPF
alignment tables (issue #169), one level deeper: policies is an object
array and each policy's failure_details is an object array inside it,
so stacked terms aggregations on their subfields fabricate rows.

Documents now also carry policies_combined ("domain / type" per policy)
and failure_details_combined ("domain / type / result / sending mta /
receiving ip / mx" per failure detail), composed at save time with the
same "none" fallbacks as the aggregate fields. migrate_indexes() gains
smtp_tls_indexes and backfills old documents with the same guarded,
non-blocking update_by_query pattern; cli.py wires the index name in on
both backends, and the manual _update_by_query command is documented.

Also fixes two adjacent dead fields: add_failure_details stored
additional_information_uri under the wrong constructor kwarg
(additional_information), and receiving_mx_hostname had no declaration
despite always being stored.

Verified live on ES 8.19 and OpenSearch 3: a two-policy repro report
yields exactly 2 policy rows and 2 failure-detail rows via the combined
fields where the old stacked aggregations return 4 of each; the startup
backfill converted the 4 pre-existing sample documents on both engines
with zero recompute mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rework the SMTP TLS dashboards onto the combined fields

Kibana/OSD: "SMTP TLS domains" replaces its stacked policy_domain ×
policy_type terms with one terms agg on policies_combined.keyword;
"SMTP TLS failure details" replaces six stacked terms spanning both
array levels with one on failure_details_combined.keyword; the
smtp_tls* index-pattern field cache gains the new fields. The
"reporting organizations" table only buckets on doc-level org_name and
needed no change.

Splunk: the base search now expands policies at the JSON level (spath +
mvexpand) so policy fields are scalars per event, and the failure
details panel expands the second level the same way — sums are the
detail's own failed_session_count, correctly paired. Verified via the
search REST API: a two-policy repro returns exactly one row per real
failure detail with per-detail counts.

kibana.md documents the per-policy/per-detail row semantics and the
honest caveat that session-count sums remain per report document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Persist additional_info_uri from parsed SMTP TLS failure details

Copilot caught that the savers read additional_information_uri from
the parsed failure-detail dict, but the parser's key is
additional_info_uri (SMTPTLSFailureDetailsOptional in types.py, set in
parse_smtp_tls_report_json), so the URI was never persisted — the
read-side half of the dead-field bug whose write-side half (wrong
constructor kwarg) was fixed earlier. Read the parser's key first,
keeping the long-form key as a fallback for dicts built by other
callers. Regression test proven to fail on the unfixed savers.

Also restructured the expected combined-string test values into named
locals so no implicit string concatenation sits inside a list literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Say "inner doc", not "nested doc", in the singular-key test docstrings

Final review sweep: in this codebase "nested" is reserved for the
Elasticsearch mapping type, and these InnerDoc-serialization
docstrings used it colloquially — the same dual-use-term trap
documented in AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:29:40 -04:00
Sean WhalenandClaude Fable 5 864f11e2be Accept directory paths as file_path arguments, add -r/--recursive (#843)
A directory given as a file_path argument now expands to the report
files inside it with shell-glob semantics (dotfile entries excluded,
subdirectories skipped). The new -r/--recursive flag descends into
subdirectories and enables '**' recursion in glob patterns. Directory
names containing glob metacharacters are escaped before expansion.
Fixes the file_path help string's stray trailing apostrophe and
refreshes the stale --help block in docs/source/usage.md.

Closes #397

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:09:42 -04:00
Sean WhalenandClaude Fable 5 4fa8cfb1e7 Skip the results email when no reports were parsed (#837)
* Skip the results email when no reports were parsed (#200)

The [smtp] (and Microsoft Graph) results email was sent unconditionally
whenever the transport was configured, so an empty run — an empty inbox,
or one where every message was invalid — still emailed a zip of
headers-only CSVs. The email step is now skipped with an INFO log when
the run produced no aggregate, failure, or SMTP TLS reports.

The regression test was verified to fail against the unfixed code
(send_email was called once with a headers-only zip). Six existing tests
that asserted on the email path with all-empty mocked results now feed a
real parsed sample aggregate report through the actual zip/CSV-building
code instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Graph-path regression test for the empty-run email guard

Copilot review on #837 pointed out that only the SMTP transport had a
skip regression test, so a refactor narrowing the guard to the SMTP
branch could silently reintroduce headers-only zips via Microsoft
Graph. The new test was verified to fail against exactly that
narrowing (send_message called once with a headers-only zip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:49:41 -04:00
e98e12acb0 Normalize aggregate result words to lowercase (#835)
* fix: normalize aggregate result words

Normalize policy-evaluated and authentication result values so mixed-case reporter output does not create duplicate categories. Add a regression covering the existing uppercase sample.

* Move changelog entry to the Unreleased section

The entry landed in the released 10.2.4 section because the branch was
cut before the Unreleased heading existed on master; merged master and
moved it under Unreleased -> Bug fixes, matching the house entry style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Apply review feedback: normalize disposition, share a guarded helper

- policy_evaluated disposition gets the same lowercase normalization as
  dkim/spf and the auth result words; the RFC 7489 Appendix C /
  RFC 9990 disposition enum is lowercase (none/quarantine/reject), and
  mixed-case values split the Message Disposition categories in every
  dashboard the same way Pass/pass did.
- All four normalization sites now share _normalize_result_word(),
  which guards with isinstance(str): xmltodict returns a dict for
  attribute-bearing elements, so the previously unguarded .lower()
  calls on auth results could raise AttributeError on malformed input
  that used to pass through.
- The #520 fixture's disposition is now mixed-case (None) and the test
  asserts it parses as "none"; verified the assertion fails against the
  pre-fix parser. Test docstring cites the RFC authority per the
  project's testing standards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:02:24 -04:00
Sean WhalenandClaude Fable 5 62514bd72a Add per-domain DMARC compliance percentage to all aggregate dashboards (#834)
* Add per-domain DMARC compliance percentage to all aggregate dashboards (#112)

The from-domain volume table on every provider's aggregate dashboard is
now "Message volume and DMARC compliance by from domain" with columns
From Domain | Messages | % DMARC Compliant:

- OpenSearch Dashboards/Kibana: the agg-based data table is replaced by
  a TSVB table using a Filter Ratio metric (passed_dmarc:true over all,
  sum of message_count), pivoted on header_from.keyword. The time field
  is date_begin rather than the multi-valued date_range, which TSVB's
  per-value date histogram would double-count. Editing (not rendering)
  the panel on Kibana 8.x requires the metrics:allowStringIndices
  advanced setting.
- Grafana (Elasticsearch): a second passed_dmarc:true query joined by
  field with a binary calculation (Sum 2 / Sum 1) rendered as percentunit.
- Grafana (PostgreSQL): compliance column via an aggregate FILTER clause,
  COALESCEd so zero-pass domains show 0 instead of NULL.
- Splunk: sum(eval(if(passed_dmarc="true", message_count, 0))) inside
  stats, per the SPL eval-in-stats syntax.

All four providers were verified against the same seeded sample data in
the dashboard dev stack; each returns identical per-domain values
(example.com: 2425 messages, 5.3% compliant).

Dev stack fixes found along the way: cap Elasticsearch heap at 2g (the
unset heap auto-sized to 50% of host RAM and was OOM-killed with
bootstrap.memory_lock on large hosts), and install the elasticsearch
datasource plugin in Grafana, which is no longer bundled as of
Grafana 13.

Closes #112

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix over-time charts double-counting reports via multi-valued date_range

date_range on ES/OpenSearch aggregate and SMTP TLS documents is a
two-element array [begin, end]. A date histogram buckets a document once
per value, so every over-time chart bucketing on date_range counted a
report twice whenever its begin and end dates fell in different buckets.
Range filtering on it was also wrong: a report spanning the whole window
matches neither endpoint.

Measured on the dev-stack sample data: a 1d histogram on date_range
returns doc_count 4592 / message sum 4724 against true totals of
2300 / 2427; the same histogram on date_begin returns exactly
2300 / 2427.

All date histograms (2 OSD/Kibana visualizations, 10 Grafana ES panels
including the summary pies) and all time-range filters (24 Grafana
target timeFields, the dmarc_aggregate* and smtp_tls* index-pattern
timeFieldName, the dev-stack dmarc-ag datasource) now use the
single-valued date_begin, matching the report-begin semantics of the
PostgreSQL (begin_date) and Splunk (_time = interval begin) dashboards.
Failure-report panels already used the single-valued arrival_date and
are unchanged.

Dev stack: installing the Elasticsearch datasource plugin via
GF_INSTALL_PLUGINS crash-loops Grafana >= 13 (the image ships a
root-owned plugins-bundled/elasticsearch remnant the background
installer cannot replace), so the bootstrap script now installs it via
grafana cli and restarts Grafana instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Copilot review comments on PR #834

- kibana.md: "filter on our filter out" -> "filter on or filter out".
- OSD/Kibana export: fix "filed  DMARC" -> "failed DMARC" and the
  backticked `ruf ` trailing space in the RUF explainer panel, and
  rename the "SMPT TLS failure details" visualization to "SMTP TLS
  failure details" (object title and visState).
- dashboard-dev-bootstrap.sh: reuse wait_for() after the Grafana
  plugin-install restart so a hang fails with a clear timeout message
  instead of an opaque downstream curl error.

The ndjson changes were round-tripped through the dev-stack OSD
(import -> re-export from the global tenant) and re-import cleanly into
both OSD and Kibana 8.19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* AGENTS.md: reviews must cover prose and hunk context, not just function

Codifies the lessons from the PR #834 Copilot review: whole-file
canonical dashboard exports put pre-existing titles/markdown in the
diff, so they get a text-level pass; proofread the full hunk around
prose edits, not only changed lines; and mid-incident glue code gets
the same review bar (and helper-reuse check) as planned code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second round of Copilot review comments

- CHANGELOG.md: rename the premature "10.2.5" heading to "Unreleased",
  matching the repo convention where the release commit assigns the
  version number (see 855d267 for 10.2.4).
- docker-compose.yml: make the dev-stack Elasticsearch heap overridable
  via ES_JAVA_OPTS in .env (default unchanged at 2g), using the compose
  file's existing ${VAR:-default} idiom, for smaller machines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 19:23:55 -04:00
Sean Whalen 855d267650 Bump version to 10.2.4 and update changelog 2026-07-20 19:51:15 -04:00
supaeasy d9f6532841 Default missing Feedback-Type and Authentication-Results in failure reports (#332) (#831)
Some Exim/cPanel-based gateways send DMARC failure reports without a
machine-readable message/feedback-report part. parse_report_email()'s
plain-text fallback synthesizes a minimal feedback report with only
Arrival-Date and Source-IP, but the Elasticsearch/OpenSearch outputs
access feedback_type and authentication_results with hard key lookups,
so every such report was archived but never indexed, failing with
"Failure report missing required field: 'feedback_type'".

parse_failure_report() now defaults feedback_type to auth-failure
(RFC 5965 3.1) and authentication_results to None (RFC 6591 3.1) with
logged warnings, matching the existing handling of the REQUIRED
Auth-Failure and Identity-Alignment fields. Adds a sanitized sample
and a regression test asserting the sink-required keys are present.
2026-07-20 15:21:55 -04:00
Sean WhalenandClaude Sonnet 5 1986835f51 Fix daily-cadence charts defaulting to sub-day time buckets (#828) (#830)
DMARC aggregate reports post one data point per reporting period
(typically daily), but several dashboard time-series panels used a
dynamically-computed bucket interval that scales with the viewed time
range/panel width instead of the data's actual cadence, producing a
spiked/gapped chart whenever that computed interval landed below a day:

- OpenSearch Dashboards: both date_histogram aggregations used
  "interval": "auto". Fixed to the "d" unit code (verified against
  OpenSearch-Dashboards' and Kibana's own _interval_options.ts /
  parse_interval.ts source — "day" is not a valid value and would throw
  at render time).
- Grafana + PostgreSQL: 7 query targets used
  $__timeGroup(col, $__interval); hardcoded to $__timeGroup(col, '1d'),
  matching the equivalent already-correct panels in the Grafana +
  Elasticsearch dashboard.
- Splunk: both `timechart` panels had no explicit span, so Splunk's
  100-bin auto-algorithm only coincidentally produced daily buckets for
  the dashboard's default 7-day view. Added span=1d per Splunk's own
  timechart documentation.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 20:17:57 -04:00
Sean WhalenandClaude Fable 5 df9bf82e04 Post-review follow-ups for Graph send (#825/#826) and requests-to-httpx migration (#827)
Follow-ups from the review of PR #825 (whose implementation had already
landed on master via #826's stacked merge):

- Honor the documented [smtp] attachment and [smtp] message options.
  Both were parsed into opts but never passed to either summary-email
  transport (also broken in released 10.2.2), so a configured custom
  attachment filename or message body was silently ignored. Both the
  SMTP and Microsoft Graph transports now receive them, and the missing
  smtp_attachment Namespace default is added (also covers SIGHUP
  reload, which rebuilds opts from the CLI Namespace).
- Don't mislabel non-Graph mailbox errors as Microsoft Graph failures:
  the shared mailbox-fetch and watch handlers now log a generic
  "Mailbox Error" with traceback when the connection isn't Graph.
- Declare microsoft-kiota-abstractions as a direct dependency (imported
  directly in cli.py for Graph error handling; previously transitive).

Migrate all runtime HTTP from requests to httpx (webhook client, Splunk
HEC client, and the PSL-overrides / IP-database / reverse-DNS-map /
IPinfo-API fetches in utils.py):

- follow_redirects=True everywhere to preserve requests' default
  redirect-following; httpx does not follow redirects by default.
- The PSL-overrides and reverse-DNS-map fetches gain a 60s timeout
  (previously none), matching the IP-database fetch.
- response.ok -> response.is_success; requests.RequestException ->
  httpx.HTTPError; raw string bodies use content= (httpx's data= is
  form-encoding only); Splunk HEC verification moves to client
  construction (httpx has no per-request verify).
- requests drops out of [project] dependencies and moves to the [build]
  extra for the out-of-wheel maintainer script collect_domain_info.py,
  which deliberately stays on requests/urllib3 for its permissive-TLS
  adapter.
- Remove the requests-era module-level
  urllib3.disable_warnings(InsecureRequestWarning) in splunk.py; httpx
  doesn't route through urllib3, so its only remaining effect was
  globally silencing insecure-TLS warnings from other urllib3-based
  components as an import side effect. Nothing imports urllib3 directly
  anymore, so it also leaves [project] dependencies.

Tests: config-to-transport wiring for attachment/message on both
transports (including defaults), non-Graph errors keep the generic log
line, webhook/Splunk payload assertions moved to content=, and Splunk
verify asserted at httpx.Client construction. 736 passed; ruff and
pyright clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:11:47 -04:00
KiliandClaude Sonnet 5 31c928d6fc Refresh Microsoft Graph docs: national clouds, examples, troubleshooting (#826)
* Send report summary via Microsoft Graph; make Graph failures observable

Two related fixes shipped together:

Send via Graph: the periodic DMARC summary email can now be sent
through the already-authenticated Microsoft Graph mailbox connection
(MSGraphConnection.send_message(), /users/{mailbox}/sendMail) instead
of only SMTP. Triggered when [msgraph] is configured and [smtp] has a
`to` value but no `host` -- SMTP is always preferred when `host` is
set, with no automatic fallback to Graph on SMTP failure. Reuses the
same connection used for reading; no new send-only config mode.
email_results()'s SMTP behavior is unchanged; a new
email_results_via_msgraph() shares its content-building logic via a
new _build_report_email_content() helper. Graph's sendMail always
sends as the authenticated mailbox, so [smtp] from is ignored on this
path -- documented, along with the required Mail.Send permissions and
a caveat that delegated auth flows (UsernamePassword/DeviceCode) don't
currently request that scope, so app-only auth is the supported path
for sending. Tracks #472.

Observable Graph failures: MSGraphConnection construction, mailbox
fetch, message send, and --watch failures now catch
ClientAuthenticationError/APIError/httpx.HTTPError specifically and
log one clear ERROR line naming the mailbox, tenant, auth method, and
the Graph request-id/client-request-id when available, instead of a
bare "MS Graph Error"/"Mailbox Error" with no context. Full traceback
still preserved at --debug. --watch previously had no Graph-specific
error handling at all -- a Graph error there crashed with a raw
uncaught traceback; it now exits the same way as the other three
sites. No new config options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Refresh Microsoft Graph docs: national clouds, examples, troubleshooting

The [msgraph] docs were accurate but missed guidance the community has
been asking for:

- graph_url now lists the actual national/sovereign-cloud endpoint
  values (GCC High, DoD, China/21Vianet), with an explicit warning
  that setting it alone is not sufficient -- the Entra ID auth
  endpoint isn't independently configurable in parsedmarc or
  mailsuite, so it always hits the global login.microsoftonline.com.
- A minimal working [msgraph] example for every auth method
  (UsernamePassword, DeviceCode, ClientSecret, Certificate,
  ClientAssertion) -- previously only Certificate had one, entangled
  with the SMTP-sending example.
- A reading-permission matrix alongside the existing sending one, so
  every auth method x own/shared-mailbox combination is explicit in
  one place for both directions.
- An accurate note on the parsedmarc-named token cache: it's a
  deliberate backward-compatibility choice from the 9.11.0 mailsuite
  extraction (mailsuite's own default cache name differs), not a
  migration users need to act on.
- A troubleshooting table for four error scenarios, verified against
  source rather than assumed: admin consent and folder-resolution
  failures are still live and documented with real fixes; the
  event-loop and ISO-timestamp errors are historical, already fixed
  below this project's dependency/version floor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:34:56 -04:00
Sean WhalenandClaude Fable 5 40509f801b Migrate Elasticsearch output to the elasticsearch-py 8.x client (#822)
* Migrate Elasticsearch output to the elasticsearch-py 8.x client (#806)

The mandatory elasticsearch<7.14.0 + elasticsearch-dsl==7.4.0 pins
transitively forced urllib3<2 (EOL 1.26.x) onto every install. The old
<7.14.0 cap only existed to dodge the client product check that broke
OpenSearch users (#452, #653) — obsolete now that parsedmarc has a
dedicated [opensearch] backend on opensearch-py.

- Depend on elasticsearch>=8.18,<9 and drop elasticsearch-dsl entirely
  (the DSL ships inside the client as elasticsearch.dsl since 8.18.0).
  The 8.x client's elastic-transport allows urllib3>=1.26.2,<3, so
  installs can now resolve urllib3 2.x. The 8.x line supports both
  Elasticsearch 8.x and 9.x servers; ES 7.x servers are no longer
  supported, and OpenSearch users pointing [elasticsearch] at an
  OpenSearch cluster must switch to the [opensearch] section.
- set_hosts() now builds 8.x connection kwargs (scheme-qualified host
  URLs, request_timeout, basic_auth) while keeping the function
  signature and every INI option unchanged.
- migrate_indexes() is now a documented no-op kept for API
  compatibility: its only migration (re-typing published_policy.fo
  from long to text) applied exclusively to indices carrying the
  legacy ES 6-era "doc" mapping type, which cannot exist on any
  server the 8.x client can reach.
- The elasticsearch.dsl 8.x stubs use dataclass_transform and don't
  surface pre-8.x-style bare `name = Text()` fields as constructor
  parameters; each Document/InnerDoc class now carries a
  TYPE_CHECKING-only `__init__(*args, **kwargs)` declaration matching
  the real runtime signature, which also made nine pre-existing
  pyright ignores unnecessary.

Verified with ruff, pyright (0 errors/0 warnings), the full pytest
suite (718 passed), and a CLI run over the bundled samples; CI's live
elasticsearch:8.19.7 service exercises the new client end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use pass instead of ... in TYPE_CHECKING __init__ stubs

CodeQL flags an ellipsis-only body as "Statement has no effect" (12
alerts on PR #822); pass is equivalent at runtime and to the type
checker and keeps the alerts from resurfacing on every future scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:50:25 -04:00
Sean Whalen a1fd66de04 Bump version to 10.2.2 2026-07-13 10:08:51 -04:00
Sean WhalenandClaude Sonnet 5 01b85ab7de Fix aggregate-report timestamp skew on non-UTC hosts (#819) (#821)
record["interval_begin"]/["interval_end"] are UTC wall-clock strings
(produced in __init__.py by strftime() on a datetime already converted
via human_timestamp_to_datetime(..., to_utc=True)), but the
Elasticsearch and OpenSearch per-record save loops and the Splunk HEC
aggregate-report event builder re-parsed them without assume_utc=True,
so on non-UTC hosts they were misinterpreted as local time and shifted
by the host's UTC offset -- shifting the stored date_begin/date_end,
the daily/monthly index date, and the Splunk event time. Verified by
parsing a real sample report under TZ=Europe/Warsaw vs TZ=UTC and by
reproducing the exact skew via human_timestamp_to_unix_timestamp().

This is the same class of bug fixed for arrival_date_utc in #811/#812
(commit cdda5da); the assume_utc keyword already exists on
human_timestamp_to_datetime()/human_timestamp_to_unix_timestamp() and
is reused here rather than reimplemented.

The issue also proposed changes to the report-level begin_date/end_date
parses in elastic.py/opensearch.py (~line 455) and to postgres.py.
Both were investigated and left unchanged: the report-level strings
are genuinely host-local time (from timestamp_to_human() ->
datetime.fromtimestamp()), so their existing no-assume_utc round-trip
is already correct on a single host -- postgres.py already gets this
right via two distinct helpers (_naive_local_to_timestamptz vs
_ensure_utc_suffix). Adding assume_utc to the report-level parses
would introduce a skew rather than fix one.

Fixes https://github.com/domainaware/parsedmarc/issues/819

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:59:35 -04:00
Sean WhalenandClaude Fable 5 746da77de5 Raise test coverage: utils.py, elastic.py, and opensearch.py to 100% (#816)
* Raise test coverage: utils, elastic, and opensearch to 100%

Coverage of the shipped library rises from 88% to 90%, with
parsedmarc/utils.py 86% -> 100% and elastic.py / opensearch.py
99% -> 100%. All new tests assert on observable behaviour and mock
only at SDK boundaries (dnspython Resolver.resolve, requests.get,
subprocess.check_call, elasticsearch_dsl/opensearchpy Document.save).

New tests cover: query_dns transient-error retries, the load_ip_db
download/cache/bundled fallback chain, the IPinfo API token probe and
per-request MMDB fallbacks, _normalize_ip_record schema handling,
reverse-DNS-map invalid-CSV fallback, caller-provided reverse DNS
maps, Outlook MSG conversion (missing msgconvert and success paths),
parse_email Cc/Bcc/attachment-hash branches, aggregate-XML edge cases
(bytes input, repeated policy_published, unknown RFC 9990 override
types, missing org_name, attribute-only <email>), extract_report on
non-seekable streams, and the _AggregateReportDoc.save() override
that derives passed_dmarc.

Bugs found by the new tests, fixed in the same PR per the testing
standards:

- parse_email() crashed with KeyError: 'Headers' on messages whose
  From header is present but unparseable (e.g. a bare "From:" line):
  the fallback read parsed_email["Headers"], but the parsed headers
  are stored under lowercase "headers" (assigned a few lines up in
  the same function), so the key never exists. At the CLI surface
  this made any failure report whose embedded sample had an empty
  From: header fail to parse ("Missing value: 'Headers'").
- configure_ipinfo_api(probe=True) logged "IPinfo API configured"
  when the probe could not reach the API, contradicting its own
  docstring ("other errors are logged and the token is still
  accepted"): _ipinfo_api_lookup() returns None on network errors
  instead of raising, so the probe's exception handler was
  unreachable. The probe now checks the lookup result and warns on
  failure; 401/403 still raises InvalidIPinfoAPIKey.

Dead code deleted rather than padded with tests:

- _SMTPTLSReportDoc.add_policy() in elastic.py and opensearch.py
  (the save paths construct _SMTPTLSPolicyDoc directly).
- The no-op "for failure_index in failure_indexes: pass" loop in
  both migrate_indexes() implementations (parameter still accepted).
- The importlib.resources ImportError fallback in utils.py, which
  re-imported the same module and is unreachable on Python >= 3.10.
- The "Invalid report content" guard in extract_report(): every
  input branch assigns file_object or raises first (confirmed by
  pyright narrowing with the guard removed).

Also widens parse_aggregate_report_xml's annotation to str | bytes
to match its existing runtime behaviour (bytes are decoded with
errors ignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use assertGreater for the reverse-DNS-map fallback size check

Addresses the github-code-quality bot finding on PR #816: assertTrue
with a comparison inside can't show the operands on failure, while
assertGreater reports both values and the failed relation. No change
to test behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:21:04 -04:00
Sean Whalen a1da7b3420 Bump version to 10.2.1 2026-07-09 20:50:45 -04:00
3fda55d385 Make Microsoft Graph connection activity observable (#815)
* Make Microsoft Graph connection activity observable

parsedmarc only configured its own logger, so all Graph connection
activity was silently dropped even with --debug: the mailbox layer
logs under mailsuite.mailbox.graph, token acquisition under
azure.identity (including the AADSTS error codes that distinguish a
local config problem from an Exchange Online / Entra ID one), and
HTTP traffic under httpx/msgraph — none of which had a handler or
level set. _main() also logged nothing around the MSGraphConnection
call, so a hang left no trace at all.

Three changes, all parsedmarc-side (no mailsuite changes needed):

- Log a redacted connection summary at INFO before connecting (auth
  method, tenant ID, client ID, mailbox, Graph URL) plus a --debug
  detail line with certificate path, token-file path, and set/not-set
  flags for secrets. Secret values are never logged; a regression
  test asserts they don't appear in captured output.
- Log a timing line after the connection object is initialized.
- Propagate parsedmarc's --verbose/--debug level and handlers to the
  dependency loggers (mailsuite, azure, msgraph, httpx, httpcore) via
  _configure_dependency_logging(), synced to exactly the parsedmarc
  logger's handlers so SIGHUP log-file swaps neither duplicate output
  nor write to closed handlers. At the default level dependency
  loggers sit at WARNING, so their warnings keep surfacing (formatted)
  without new noise.

All four new tests fail on the unfixed code (verified by stashing the
cli.py change).

Fixes https://github.com/domainaware/parsedmarc/issues/814.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Disable propagation on dependency loggers; document kiota's absence

Set propagate=False on the dependency loggers when syncing handlers, so
a stray logging.basicConfig() anywhere in the process cannot
double-print every dependency record through the root logger — the
function already owns these loggers' handler lists, and this makes that
ownership complete. Asserted alongside the existing level/handler checks.

kiota_http and its sibling packages were considered for
_DEPENDENCY_LOGGERS but verified to not use Python logging at all
(their observability is OpenTelemetry tracing), so a comment now
records why they are absent rather than leaving the omission to be
"fixed" later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: MISAPOR LAB <misapor@lab.misapor.pl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
2026-07-09 20:46:39 -04:00
7fed72798a Stop system GeoIP files from shadowing the bundled IPinfo database (#813)
* Stop system GeoIP files from shadowing the bundled IPinfo database

_get_ip_database_path() searched well-known system paths (including
/usr/share/GeoIP/GeoLite2-Country.mmdb and CWD-relative names) before
the database parsedmarc manages, so on any host with a distro GeoIP
package installed every lookup silently used a country-only — and
often years-old — database instead of the bundled IPinfo Lite one.
That disabled ASN enrichment entirely (asn/as_name/as_domain were None
for every IP) and with it the ASN-fallback path into the reverse-DNS
map, with no signal beyond a generic "IP database is more than a
month old" warning. Verified live on a Fedora host whose distro
GeoLite2-Country.mmdb dated to December 2019.

New precedence: explicit ip_db_path -> _IP_DB_PATH selected by
load_ip_db() (downloaded/cached/bundled) -> the bundled copy -> system
paths as a true last resort (only consulted when the bundled data
file is missing). The selected file is logged at debug level so a
--debug run shows which database answered.

The automatic system-path pickup was documented behavior, so
installation.md now tells MaxMind GeoLite2 users to set ip_db_path
explicitly, with a migration note.

Both new regression tests reproduce the shadowing portably via a decoy
CWD GeoLite2-Country.mmdb (the fallback list includes relative names),
and fail on the unfixed code (verified by stashing the source change).

Fixes https://github.com/domainaware/parsedmarc/issues/810.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: accurate fallback comment, dedup selection log, cover fallback tiers

- Correct the nothing-found-anywhere comment: the os.stat() age check
  raises FileNotFoundError before the caller's open_database() would.
- Log "Using IP database at ..." only when the selected path changes
  instead of on every uncached IP lookup, so --debug runs over large
  batches aren't flooded; tracked via _LAST_LOGGED_IP_DB_PATH, reset in
  the test fixture for order-independence.
- Cover the previously untested branches of _get_ip_database_path:
  system-path fallback when the bundled database is missing, the
  FileNotFoundError when nothing exists anywhere, the stale-database
  warning, and the log-once-per-path behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: MISAPOR LAB <misapor@lab.misapor.pl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
2026-07-09 20:07:00 -04:00
cdda5dae62 Fix failure-report timestamp skew on non-UTC hosts in ES/OpenSearch/Splunk outputs (#812)
* Fix failure-report timestamp skew on non-UTC hosts in ES/OS/Splunk sinks

arrival_date_utc is a UTC wall-clock string (generated in
parse_failure_report via an aware-UTC strftime), but elastic.py,
opensearch.py, and splunk.py parsed it back into a naive datetime and
called .timestamp(), which per the Python docs interprets naive values
as local time (https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp).
On any non-UTC host the epoch stored as the ES/OpenSearch arrival_date
field, used in the failure-report dedup match query, and sent as the
Splunk HEC event time was therefore off by the host's UTC offset
(verified -3600 s under TZ=Europe/Warsaw in January).

Add an assume_utc keyword to human_timestamp_to_datetime() /
human_timestamp_to_unix_timestamp() that attaches timezone.utc to naive
parses, and use it at the three arrival_date_utc call sites. Aware
inputs (explicit offsets) are unaffected; all other callers keep the
existing local-time semantics, whose round-trip with timestamp_to_human
is self-consistent on a single host (the broader local-time output
question is tracked separately in issue #811 bug 2).

The three new sink regression tests fail on the unfixed code
(verified by stashing the source changes) and force TZ=Europe/Warsaw
via time.tzset() so they catch the skew even on UTC CI runners.

Fixes half of https://github.com/domainaware/parsedmarc/issues/811.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deduplicate TZ-forcing test boilerplate; fix unix-timestamp docstring

Extract the repeated TZ=Europe/Warsaw + time.tzset() setup/cleanup from
the four timestamp regression tests into a shared tests/tzutil.py
force_tz() helper, and correct human_timestamp_to_unix_timestamp()'s
docstring, which said the return type was float while the function
returns int.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: MISAPOR LAB <misapor@lab.misapor.pl>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
2026-07-09 19:45:34 -04:00
Sean WhalenandClaude Opus 4.8 d13eb86782 Advertise supported Python versions via trove classifiers
requires-python stays ">=3.10" (already correct and matching the CI matrix
of 3.10-3.14); add the per-version Programming Language :: Python :: 3.10-3.14
classifiers and "3 :: Only" so the PyPI page lists supported versions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:07:50 -04:00
Sean WhalenandClaude Opus 4.8 a67e8d3ebc 10.2.0 - Explain why a report is invalid instead of "Not a valid report" (#802)
* Explain why a report is invalid instead of "Not a valid report"

The parser catches broadly so one malformed report can't crash a batch,
but every failure surfaced as the generic ParserError("Not a valid
report"), telling operators nothing about the cause.

parse_report_file() now keeps each format parser's specific error as it
tries aggregate XML -> SMTP TLS JSON -> report email, and when all three
reject the input it content-sniffs the leading byte to surface the single
relevant reason (e.g. "Invalid aggregate report: Missing field:
'org_name'", or "Not a recognized report format (...)"). The CLI already
logs str(error), so this reaches the user with no cli.py change.

Every parser catch site also re-raises with `raise ... from <original>`,
preserving the underlying ExpatError / JSONDecodeError / KeyError /
archive errors on __cause__ for library callers and tracebacks. The same
exception *types* are still raised.

Finally, the catch-all "unexpected error" branches append
`(raised at <file>:<line>)` from the deepest traceback frame, but only
when the parsedmarc logger is at DEBUG level (e.g. the CLI's --debug);
normal-level output is unchanged.

Bumps the in-progress version to 10.2.0 and documents all three in the
CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover the failure-report path in parse_report_file error tests

The reason-surfacing tests covered the aggregate and SMTP TLS branches but
not failure reports, which reach parse_report_file only via the email
path. Add a malformed multipart/report failure email (missing the required
Source-IP) and assert the message names the failure format and the missing
field rather than collapsing to "Not a valid report".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover the new error-reporting lines; drop one unreachable catch

Bring the lines added by this PR to full test coverage:

- Test _exc_origin() with a never-raised exception (no __traceback__) so the
  "no frames" guard is exercised.
- Test _parse_smtp_tls_failure_details() with a non-dict, which raises
  TypeError (not KeyError) and exercises the generic catch-all.
- Test parse_report_email() with an unparseable Date header, which trips the
  initial mail-parse catch-all and becomes a ParserError.

Two dead lines are removed rather than hidden, per the project's "delete
unreachable branches, no # pragma: no cover" rule:

- _looks_like_email() looped with a `continue` for blank lines, but every
  caller passes lstrip()-ed text, so the first line is never blank. Simplified
  to inspect the first line directly.
- parse_report_email()'s `except Exception` after `except InvalidFailureReport`
  was unreachable: parse_failure_report wraps its entire body and provably
  raises only InvalidFailureReport, which the preceding handler already catches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix IndexError when backfilling envelope_from from SPF results

_parse_report_record() backfills a missing/empty envelope_from from the
last SPF auth result's domain. The "envelope_from is None" branch gated on
the raw auth_results["spf"] list but indexed the filtered
new_record["auth_results"]["spf"] list, which only holds results that have
a domain. A reporter sending an SPF result with no domain made the filtered
list empty while the raw list was non-empty, so [-1] raised IndexError and
the whole record failed to parse.

The two near-identical envelope_from backfill branches (missing identifier
vs. empty identifier) drifted apart -- only one was updated when the
filtered new_record list was introduced -- which is what let them disagree
on which list to read. Merge them into a single path, keyed on
dict.get("envelope_from") is None, that gates and indexes the same raw list
with the "domain" membership guard the missing-identifier branch already
used.

Regression test: envelope_from=None with an SPF result carrying no domain
now parses to envelope_from=None instead of raising. This is the bug that
motivated the surrounding error-reporting work in this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Trim comment

* Cover the touched error branches; drop a dead UnicodeDecodeError catch

Codecov flagged the pre-existing error branches this PR touched (adding
`from e` / `_exc_origin`) as changed-and-uncovered. Most are real
malformed-report paths, so add honest tests that drive them with realistic
inputs:

- parse_smtp_tls_report_json: nested missing key (date-range without
  start-datetime) -> InvalidSMTPTLSReport chaining a KeyError.
- parse_aggregate_report_xml: non-structured report_metadata -> the
  AttributeError branch ("Report missing required section").
- parse_report_email: valid legacy text/plain failure report (success path),
  a text report missing its fields, a base64 attachment of malformed
  aggregate XML, and one of invalid SMTP TLS JSON.
- parse_report_file: gzipped junk -> the str branch of the content sniff.

The `except UnicodeDecodeError` in extract_report is removed as dead code
(no `# pragma: no cover`, per the repo rule): str-mode streams are already
rejected by explicit isinstance checks, and every decode() uses
errors="ignore", so it can never fire. str-mode still raises ParserError.

Also rename the two new failure-report tests from "Forensic" to "Failure"
and add an AGENTS.md rule: RUF reports are "failure reports"; "forensic" is
reserved for the literal backward-compat alias identifiers only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix pyright errors in the new error-branch tests

CI runs pyright over the whole repo (tests included); these slipped through
because the local check only covered parsedmarc/__init__.py:

- _parse_smtp_tls_failure_details("not a dict") is a deliberate wrong-type
  test -> targeted `# pyright: ignore[reportArgumentType]`.
- result["report"]["source"]["ip_address"] on a ParsedReport TypedDict ->
  cast(FailureReport, result["report"]) first, matching existing tests.

This is what failed lint-docs-build (and, since `test` needs it, skipped the
Codecov upload) on the prior commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:53:56 -04:00
Sean Whalen 8275665e11 10.1.1
- Require `mailsuite>=2.2.2.2` to  honor config reloading in the IMAP `IDLE` loop
2026-06-13 20:46:57 -04:00
Sean WhalenandClaude Fable 5 eaeea4f53d Make the whole codebase pass pyright cleanly and enforce it in CI (#798)
* Make the whole codebase pass pyright cleanly and enforce it in CI

Fix all 102 pyright (1.1.410, standard mode) errors across the library,
tests, and maps scripts, then pin and enforce the zero-errors bar:

- postgres.py: make the optional psycopg import TYPE_CHECKING-aware so
  the module is properly typed while keeping the runtime install-hint
  fallback; import psycopg.types.json explicitly as psycopg_json (the
  old psycopg_types.json attribute access only worked because psycopg
  imports the submodule eagerly); have _connect()/_ensure_connected()
  return the live connection so save methods use a non-Optional local;
  type the DDL list as list[LiteralString] to match psycopg's execute()
  overloads.
- kafkaclient.py: resolve the kafka-python 2.x/3.x bootstrap-error
  fallback statically via TYPE_CHECKING (kafka-python 3.0 removed
  NoBrokersAvailable), which also fixes _BootstrapError's import
  resolution in tests.
- syslog.py: go through getattr/setattr for SysLogHandler.socket
  (absent from typeshed); type the save_* methods with the report
  TypedDicts (single or list, matching cli.py call sites — gelf.py gets
  the same signatures); raise ValueError when retry_attempts < 1
  instead of falling through and registering a None handler (bug fix,
  with a regression test and a CHANGELOG entry).
- elastic.py / opensearch.py: human_result params are Optional[str].
- maps scripts: sort_csv declared a return type but never returned
  (now -> None); seen_sort_field_values was possibly unbound;
  convert_to_utf8's src_encoding is Optional[str].
- tests: cast sample-report dict helpers to their TypedDicts; mark
  deliberate wrong-type calls with targeted pyright ignores; add
  narrowing asserts for Optional results; access the mocked
  KafkaProducer through a cast helper; match the mailsuite
  fetch_message base signature (**kwargs); patch the renamed
  parsedmarc.postgres.psycopg_json in test_postgres's setUpModule.

Enforcement: [tool.pyright] in pyproject.toml (include parsedmarc,
tests, docs; standard mode), pyright==1.1.410 pinned in the [build]
extra (pinned exactly so a new pyright release can't break CI without a
code change), and a "Check types" step in the lint CI job — which now
also runs ruff format --check and installs the [postgresql] extra so
the optional psycopg import resolves. Documented in AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Set session headers via update() instead of replacing the dict

requests 2.34 ships inline type annotations, and Session.headers is a
CaseInsensitiveDict[str] — assigning a plain dict fails pyright there
(the CI runner resolved 2.34.2; the local venv's untyped 2.32.4 hid
it). headers.update() is correctly typed against both versions, and is
the documented requests idiom: it overrides User-Agent and the
client-specific headers while keeping the session's defaults
(Accept-Encoding, Connection) instead of wiping them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:33:01 -04:00
Sean WhalenandClaude Fable 5 ebc6a55715 Switch from kafka-python-ng to kafka-python>=2.3.2 (#795) (#796)
kafka-python-ng is archived and vulnerable to CVE-2026-10142 and
CVE-2026-10143, both fixed in upstream kafka-python 2.3.2.

kafka-python 3.0 removed the NoBrokersAvailable exception (a failed
producer bootstrap now raises KafkaTimeoutError), so kafkaclient.py
imports whichever the installed version provides via a compat shim,
keeping the >=2.3.2 range honest for both 2.x and 3.x. Verified against
kafka-python 3.0.0 (full test suite) and 2.3.2 (import shim resolution).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:25:35 -04:00
Sean Whalen a4be378e30 Update the changelog 2026-06-12 20:14:02 -04:00
d3510da3a6 feat: graceful SIGTERM/SIGINT shutdown for watch mode and one-shot CLI (#794)
* feat: graceful SIGTERM/SIGINT shutdown for watch mode and one-shot CLI

Previously SIGTERM (systemctl stop, docker stop, Kubernetes pod termination)
killed parsedmarc mid-batch, tearing output writes and silently dropping
buffered Kafka records. Shutdown is now cooperative:

- SIGTERM/SIGINT set a flag that is polled at safe boundaries. The one-shot
  CLI checks it between batches; watch mode passes it as `config_reloading` so
  the mailbox backend -- including the IMAP IDLE loop -- returns once the
  current batch is fully processed. Either way the in-flight batch and its
  output writes finish before the process exits 0.
- Ctrl-C is a double-tap: the first press is graceful, the second
  short-circuits to os._exit(130).
- Output clients are now closed on every exit path (atexit plus a trailing
  close in _main), fixing a long-standing leak where one-shot runs and
  graceful shutdowns never flushed Kafka / closed Elasticsearch / S3 / etc.

Docs: the example systemd unit gains KillSignal=SIGTERM and TimeoutStopSec=60
(keep it above mailbox_check_timeout). Tests cover watch shutdown, the one-shot
between-batch stop, the SIGINT double-tap, and the output-client-close leak.

* test: cover the one-shot mbox-loop shutdown break

Extend the one-shot SIGTERM test to also pass an .mbox path so a single
run exercises both shutdown checkpoints: the file-batch loop break and the
subsequent mbox loop break (which Codecov flagged as the only uncovered
lines on PR #794). is_mbox is keyed by suffix and get_dmarc_reports_from_mbox
is asserted not called, since the mbox loop breaks before reaching it.

* test: narrow signal.getsignal() return before invoking in SIGINT test

signal.getsignal() is typed Callable | int | Handlers | None; calling it
directly fails pyright's callable check. Assert callable() first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:00:32 -04:00
Sean WhalenandClaude Opus 4.8 b869235224 Build multi-arch (amd64+arm64) Docker images with PostgreSQL support (#793)
* Build multi-arch Docker images with PostgreSQL support

The prebuilt image now installs the `[postgresql]` extra, so the optional
PostgreSQL output backend (psycopg) works out of the box in the container
without a separate `pip install` (#792). The wheel path is resolved into a
variable before appending the extra so the shell doesn't treat
`*.whl[postgresql]` as a bracket glob.

The build workflow now sets up QEMU + Buildx and builds a multi-arch
manifest for `linux/amd64` and `linux/arm64`, so the image runs natively on
64-bit ARM hosts such as a Raspberry Pi (#789). Every compiled dependency
(psycopg[binary], lxml, maxminddb, cryptography) ships prebuilt aarch64
manylinux wheels, so the arm64 build adds no source-compilation step.

A `pull_request` trigger (scoped to the build inputs) and `workflow_dispatch`
are added so the multi-arch build can be validated on PRs and rebuilt on
demand; pushes are still gated on the release event, so neither pushes images.

Closes #789
Closes #792

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Bump version to 10.0.4 to publish the new images

The docker workflow only pushes to the registry on a `release` event, so
shipping the multi-arch + PostgreSQL-enabled image requires cutting a
release. 10.0.3 is already tagged, so bump to 10.0.4 and document the
Docker changes in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Don't run the docker build on pull requests

The pull_request trigger (added to validate the multi-arch build) re-ran the
full ~10-minute amd64+arm64 build on every commit pushed to a docker-touching
PR, because the pull_request `paths` filter matches against the PR's entire
diff, not just the newest commit. That is wasteful once the build has been
validated.

Drop the pull_request trigger and rely on workflow_dispatch for on-demand
validation (plus the existing master-push and release triggers). Also gate the
registry login on the release event so that no non-release run authenticates
to ghcr at all — a build can only ever be pushed from a published release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:42:00 -04:00
Sean WhalenandClaude Opus 4.7 e104f1118c Land 10.0.3 changes on master (#785)
PR #784 was stacked on the #783 branch and its base was never retargeted to
master, so it merged into fix/mailsuite-2.2.1-empty-address instead of master.
master therefore has 10.0.2 (#783's squash) but is missing the 10.0.3 changes.

This re-lands exactly that delta — the Reply-To/Delivered-To parser fix, the
ES/OS Reply-To header flattening, and the Splunk/OpenSearch/Grafana failure
dashboard fixes, with the version bumped to 10.0.3. No mailsuite re-bump (the
>=2.2.1 floor is already on master from 10.0.2).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:54:40 -04:00
Sean WhalenandClaude Opus 4.7 2c8b2c0f14 Bump mailsuite to >=2.2.1 (release 10.0.2) (#783)
* Bump mailsuite to >=2.2.1; release 10.0.2

mailsuite 2.2.1 raises the transitive mail-parser floor to >=4.2.1, which
stops mail-parser from returning a phantom ('', '') entry for absent address
headers (verified against samples/failure/* with mail-parser 4.2.1: cc/bcc
now parse to [] instead of [{address: ""}]). parsedmarc reads the mail-parser
object directly via its own parse_email(), so this previously caused an empty
{address: ""} Cc/Bcc entry to be indexed for every failure-report sample in
Elasticsearch/OpenSearch and emitted in JSON/S3/Kafka output.

The Reply-To-always-empty behavior in parsedmarc's own parse_email() (a
hyphen-vs-underscore key mismatch, not an upstream issue) and the failure
dashboards are out of scope here and tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: note CVE-2023-27043 hardening from mail-parser 4.2.1 in 10.0.2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:57:50 -04:00
Sean Whalen 3f64e30f6f Update version to 10.0.1 and bump mailsuite requirement to >=2.2.0 2026-05-23 22:08:34 -04:00