Files
parsedmarc/parsedmarc/__init__.py
T
Sean WhalenandClaude Fable 5.2 771c6df0bd PostgreSQL connection follow-ups and the message_count mapping (#959)
- Accept a URI whose host has an empty port (postgresql://db:/d?
  sslrootcert=/a@b): libpq parses it as no port, but _port_is_numeric()
  treated it as non-numeric and the early refusal rejected the string.
- Close the connection when setting autocommit or registering the
  NUL-safe dumpers fails after psycopg.connect() succeeded, including
  on KeyboardInterrupt; an error from close() is dropped.
- With a connection string, report a create_tables() failure with a
  fixed message (plus a well-formed SQLSTATE), since the server's text
  can quote a session setting from the string's options; with the
  separate settings keep the server's text, URL credentials masked.
  Both raise from None. The CLI now closes a PostgreSQL client whose
  create_tables() raised, as it is not yet in the clients to roll back.
- Declare the Elasticsearch and OpenSearch aggregate documents'
  message_count and original_timespan_seconds as Long() instead of the
  bare Integer class, matching the long type dynamic mapping has always
  given them (the document classes' mappings are never installed).
  Correct the MAX_MESSAGE_COUNT comment, the _smtp_tls_count docstring
  and the GHSA-fxr7 changelog entry, which described an integer
  Elasticsearch/OpenSearch mapping of the counts that is not installed.

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 19:15:01 -05:00

5181 lines
215 KiB
Python

# -*- coding: utf-8 -*-
"""A Python package for parsing DMARC reports"""
from __future__ import annotations
import binascii
import email
import email.errors
import email.message
import email.utils
import functools
import hashlib
import ipaddress
import json
import logging
import mailbox
import os
import quopri
import re
import shutil
import tempfile
import time
import traceback
import xml.parsers.expat as expat
import zipfile
import zlib
from base64 import b64decode
from collections import deque
from collections.abc import Callable, Sequence
from csv import DictWriter
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone, tzinfo
from io import BytesIO, StringIO
from typing import (
Any,
BinaryIO,
cast,
)
import lxml.etree as etree
import mailparser
import xmltodict
from expiringdict import ExpiringDict
from mailsuite.smtp import send_email
from mailsuite.utils import MessageLimitExceeded, check_message_limits
from tqdm import tqdm
from parsedmarc.attempts import MAX_PARSE_ATTEMPTS, AttemptStore, attempt_key
from parsedmarc.config import (
IP_ADDRESS_CACHE,
REVERSE_DNS_MAP,
SEEN_AGGREGATE_REPORT_IDS,
ParserConfig,
)
from parsedmarc.constants import (
DEFAULT_DNS_MAX_RETRIES,
DEFAULT_DNS_TIMEOUT,
MAX_FAILED_REVERSE_DNS_SECONDS_PER_REPORT,
MAX_REVERSE_DNS_SECONDS_PER_REPORT,
SLOW_REVERSE_DNS_SECONDS,
MAX_MESSAGE_COUNT,
MAX_NORMALIZED_RECORDS,
MAX_REPORT_FUTURE_DAYS,
MAX_REPORT_SPAN_DAYS,
MAX_XML_DEPTH,
MAX_XML_NODES,
MAX_XML_TAG_LENGTH,
__version__,
)
from parsedmarc.log import logger
from parsedmarc.mail import (
GmailConnection,
IMAPConnection,
MailboxConnection,
MaildirConnection,
MSGraphConnection,
)
from parsedmarc.types import (
AggregateReport,
FailureReport,
ForensicReport as ForensicReport,
ParsedReport,
ParsingResults,
ReportType,
SMTPTLSReport,
)
from parsedmarc.utils import (
IPAddressInfo,
convert_outlook_msg,
get_base_domain,
get_filename_safe_string,
get_ip_address_info,
human_timestamp_to_datetime,
human_timestamp_to_unix_timestamp,
is_outlook_msg,
parse_email,
sanitize_for_log,
timestamp_to_human,
)
logger.debug(f"parsedmarc v{__version__}")
# A message/feedback-report part is a MIME body part, so its lines end with
# CRLF per RFC 5322 §2.1. In re.MULTILINE, ``$`` matches before the LF but not
# before the CR, so the value must exclude the line ending explicitly rather
# than relying on ``.`` — otherwise every field value keeps a trailing CR.
feedback_report_regex = re.compile(r"^([\w\-]+): ([^\r\n]+)\r?$", re.MULTILINE)
xml_header_regex = re.compile(r"^<\?xml .*?>", re.MULTILINE)
# Every pattern here runs on attacker-controlled input; the next two were
# rewritten to match in linear time (GHSA-4h9r-c435-j834, GHSA-7grx-mj6g-qxmc):
# a repeated character class excludes the delimiter it stops on, adjacent
# quantifiers never overlap, and text_report_regex is anchored to a line start,
# so each candidate match is scanned once instead of being rescanned from every
# offset on every backtrack. See "Regular expressions on untrusted input" in
# AGENTS.md.
xml_schema_regex = re.compile(r"</?xs:schema[^<>]*>")
text_report_regex = re.compile(
r"^[ \t]*([A-Za-z]+(?: [A-Za-z]+)*):[ \t]+([^\r\n]+)", re.MULTILINE
)
# Captures the value of any xmlns (default or prefixed) declaration so the
# RFC 9990 namespace can be detected before xmltodict drops it.
xml_namespace_regex = re.compile(
r"""xmlns(?::[a-zA-Z_][\w.-]*)?\s*=\s*["']([^"']+)["']"""
)
# The XML namespace assigned to DMARC aggregate reports by RFC 9990.
RFC_9990_NAMESPACE = "urn:ietf:params:xml:ns:dmarc-2.0"
# PolicyOverrideType enumeration from RFC 9990. Compared to RFC 7489,
# `policy_test_mode` was added (emitted when t=y suppresses enforcement)
# and `forwarded` / `sampled_out` were removed.
RFC_9990_POLICY_OVERRIDE_TYPES = frozenset(
{
"local_policy",
"mailing_list",
"other",
"policy_test_mode",
"trusted_forwarder",
}
)
RFC_7489_REMOVED_POLICY_OVERRIDE_TYPES = frozenset({"forwarded", "sampled_out"})
MAGIC_ZIP = b"\x50\x4b\x03\x04"
MAGIC_GZIP = b"\x1f\x8b"
MAGIC_XML = b"\x3c\x3f\x78\x6d\x6c\x20"
MAGIC_XML_TAG = b"\x3c" # '<' - XML starting with an element tag (no declaration)
# 0x7B, "{" -- a JSON text that is an object begins with it (RFC 8259).
# Previously written as b"\7b", which Python reads as the octal escape
# \7 (BEL) followed by a literal "b", so the branch never matched real
# JSON; every in-tree caller happened to pre-guard with its own zip/gzip
# or "{" check, which masked it.
MAGIC_JSON = b"\x7b"
# Maximum size, in bytes, of the data extracted from one compressed report
# attachment, and of an uncompressed report or report file measured as UTF-8
# (_exceeds_size_cap()), so a non-ASCII text cannot be four times the cap in
# bytes. Real reports compress at roughly 10:1, but the attachment's
# content is chosen by whoever sent it, and deflate (used by both gzip and
# zip) reaches about 1000:1 on degenerate input, so a 32 KB attachment from
# any sender a monitored mailbox accepts could inflate to 32 MB -- and the
# decoded str coexists with the decompressed bytes, so the peak is roughly
# twice that. Deliberately not configurable: an aggregate report is a day of
# one reporter's traffic, the largest sample is 909 KB, and the XML node
# limit (MAX_XML_NODES, 1,000,000 nodes at the samples' 35 nodes per KB)
# already rejects a report of more than about 28 MB (GHSA-q7gm-8cj9-94hr).
MAX_DECOMPRESSED_REPORT_SIZE = 32 * 1024 * 1024
# Per-message count of consecutive failed saves, keyed on
# ``(reports_folder, str(message_uid))``. Populated only when
# ``get_dmarc_reports_from_mailbox()`` is given a ``save_callback`` that
# reports a batch as unsaved; a message whose count exceeds
# ``max_unsaved_retries`` is moved to ``{archive_folder}/Unsaved`` instead
# of being retried forever, and its entry is dropped. A successful save
# clears the entries for that batch's messages.
#
# Process-local and deliberately not persisted: a restart re-attempts every
# message still in the reports folder, which is the safe direction (retry
# rather than shelve). The key carries no connection identity, so a library
# caller processing two connections that share a folder name (two IMAP
# servers, both "INBOX") in one process could collide counters if UIDs
# happen to match; the CLI uses one connection per process. It is not a
# ``ParserConfig`` field for the same reason ``batch_size`` and the
# ``delete`` flags are not -- it governs mailbox orchestration, not
# parsing.
_FAILED_SAVE_ATTEMPTS: dict[tuple[str, str], int] = {}
EMAIL_SAMPLE_CONTENT_TYPES = (
"text/rfc822",
"text-rfc-822",
"text/rfc822-headers",
"text/rfc-822-headers",
"message/rfc822",
"message/rfc-822",
"message/rfc822-headers",
"message/rfc-822-headers",
)
class ParserError(RuntimeError):
"""Raised whenever the parser fails for some reason"""
class InvalidDMARCReport(ParserError):
"""Raised when an invalid DMARC report is encountered"""
class InvalidSMTPTLSReport(ParserError):
"""Raised when an invalid SMTP TLS report is encountered"""
class ParseTimeoutError(InvalidDMARCReport):
"""Returned in place of a report whose parsing exceeded ``parse_timeout``
An ``InvalidDMARCReport``, so a timed-out message is handled like any
other message that cannot be parsed: the mbox loop warns and continues,
and the mailbox loop moves it to the ``Invalid`` archive folder. It is
the value for every timed-out input, including one that would have
been an SMTP TLS report or not a report at all, since a parse that was
stopped cannot tell what the input held.
"""
class InvalidAggregateReport(InvalidDMARCReport):
"""Raised when an invalid DMARC aggregate report is encountered"""
class InvalidFailureReport(InvalidDMARCReport):
"""Raised when an invalid DMARC failure report is encountered"""
# Backward-compatible alias
InvalidForensicReport = InvalidFailureReport
_DOCTYPE_ERROR = "Aggregate reports must not contain a DOCTYPE declaration"
class _XMLDoctypeFound(Exception):
"""Raised by :class:`_NoDoctypeExpat` parsers as soon as expat reports
the start of a ``<!DOCTYPE`` declaration."""
def _raise_doctype_found(*args: Any) -> None:
raise _XMLDoctypeFound(_DOCTYPE_ERROR)
class _NoDoctypeExpat:
"""A minimal stand-in for the ``xml.parsers.expat`` module, passed to
``xmltodict.parse(expat=...)``, whose parsers abort on any DOCTYPE.
An internal DTD subset can declare default attributes
(``<!ATTLIST z a0 CDATA "v" ... aK ...>``) that expat then reports on
every matching element, so K declared defaults on N empty elements make
xmltodict build K x N dict entries from a few kilobytes of input
(GHSA-m47j-wp78-fqjg). Entity expansion is already off
(xmltodict's ``disable_entities=True``) but does not cover this.
DMARC aggregate reports never carry a DOCTYPE, so the declaration is
rejected outright, before expat reads any of the subset.
``xmltodict.parse()`` only ever calls ``ParserCreate()`` on the module it
is given.
"""
@staticmethod
def ParserCreate(*args: Any, **kwargs: Any) -> Any:
parser = expat.ParserCreate(*args, **kwargs)
parser.StartDoctypeDeclHandler = _raise_doctype_found
return _LimitedExpatParser(parser)
class _XMLLimitExceeded(Exception):
"""Raised by :class:`_LimitedExpatParser` as soon as a document goes over
``MAX_XML_DEPTH`` or ``MAX_XML_NODES``."""
class _LimitedExpatParser:
"""Wraps an expat parser so that the element handlers xmltodict installs
count nesting depth and nodes (elements plus attributes), and raise
:class:`_XMLLimitExceeded` over ``MAX_XML_DEPTH`` or ``MAX_XML_NODES``.
xmltodict keeps one stack entry per open element, so a document of
millions of unclosed ``<a>`` tags, or of millions of siblings, could cost
gigabytes before expat reported the error at the end of the input
(GHSA-q7gm-8cj9-94hr). The check runs before xmltodict's handler, so the
tree never grows past the limits.
pyexpat parser objects cannot be subclassed, and xmltodict assigns its
handlers after ``ParserCreate()`` returns, so this proxy wraps the
handlers in ``__setattr__`` and forwards every other attribute. The
wrappers keep their counts in a :class:`_XMLCounts`, not on the proxy:
a wrapper referring to the proxy would close a reference cycle through
the parser and keep xmltodict's whole tree alive until the cyclic
garbage collector ran, doubling peak memory across the two parses.
"""
def __init__(self, parser: Any) -> None:
object.__setattr__(self, "_parser", parser)
object.__setattr__(self, "_counts", _XMLCounts())
def __getattr__(self, name: str) -> Any:
return getattr(self._parser, name)
def __setattr__(self, name: str, value: Any) -> None:
if name == "StartElementHandler":
value = _limit_start(value, self._counts)
elif name == "EndElementHandler":
value = _limit_end(value, self._counts)
setattr(self._parser, name, value)
class _XMLCounts:
"""The open-element depth and node count of one expat parse."""
def __init__(self) -> None:
self.depth = 0
self.nodes = 0
def _limit_start(handler: Callable[..., Any], counts: _XMLCounts) -> Callable[..., Any]:
"""Wrap an expat start-element handler so that it counts depth and
nodes in ``counts`` and raises :class:`_XMLLimitExceeded` over the
limits before ``handler`` runs."""
def start(name: str, attributes: list[str]) -> Any:
depth = counts.depth + 1
# xmltodict sets ordered_attributes, so expat passes attributes as
# a flat [name, value, name, value, ...] list.
nodes = counts.nodes + 1 + len(attributes) // 2
if depth > MAX_XML_DEPTH:
raise _XMLLimitExceeded(
f"elements nested more than {MAX_XML_DEPTH} levels deep"
)
if nodes > MAX_XML_NODES:
raise _XMLLimitExceeded(
f"more than {MAX_XML_NODES:,} elements and attributes"
)
counts.depth = depth
counts.nodes = nodes
return handler(name, attributes)
return start
def _limit_end(handler: Callable[..., Any], counts: _XMLCounts) -> Callable[..., Any]:
"""Wrap an expat end-element handler so that it decrements the depth in
``counts``."""
def end(name: str) -> Any:
counts.depth -= 1
return handler(name)
return end
class _LxmlNodeCounter:
"""An lxml parser target that counts elements, attributes and namespace
declarations without building a tree, and raises
:class:`_XMLLimitExceeded` past ``MAX_XML_NODES``.
It runs before the lxml recovery parse, whose tree would otherwise cost
gigabytes for a wide document behind a malformed XML declaration
(GHSA-q7gm-8cj9-94hr). Depth is not counted here: libxml2 stops at 256
levels without ``huge_tree``, and the second ``xmltodict.parse()``
enforces ``MAX_XML_DEPTH`` on the recovered document.
"""
def __init__(self) -> None:
self.nodes = 0
def _add(self, count: int) -> None:
self.nodes += count
if self.nodes > MAX_XML_NODES:
raise _XMLLimitExceeded(
f"more than {MAX_XML_NODES:,} elements and attributes"
)
def start(self, tag: str, attrib: dict[str, str]) -> None:
self._add(1 + len(attrib))
def start_ns(self, prefix: str | None, uri: str) -> None:
# xmltodict, which does not process namespaces, sees each xmlns
# declaration as an attribute.
self._add(1)
def end(self, tag: str) -> None:
pass
def close(self) -> int:
return self.nodes
def _parse_xml_with_recovery(xml: bytes) -> Any:
"""Parse ``xml`` with lxml in recovery mode and return the tree, after a
counting pass that raises :class:`_XMLLimitExceeded` over
``MAX_XML_NODES``, so the tree is only built for a document within the
limit."""
etree.parse(
BytesIO(xml),
etree.XMLParser(
recover=True, resolve_entities=False, target=_LxmlNodeCounter()
),
)
return etree.parse(
BytesIO(xml), etree.XMLParser(recover=True, resolve_entities=False)
)
def _resolve_config(
config: ParserConfig | None,
*,
offline: bool = False,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
normalize_timespan_threshold_hours: float = 24.0,
) -> ParserConfig:
"""Resolve the effective :class:`~parsedmarc.config.ParserConfig` for a
public parsing call, from either an explicit ``config`` or the caller's
individual option keyword arguments.
If ``config`` is not ``None``, it is returned unchanged: per the
documented ``config=`` contract, the individual option keyword arguments
are ignored in favor of the config's own values, so no merging happens
here.
Otherwise, a new ``ParserConfig`` is built from the given keyword
arguments. Its three cache fields are deliberately *not* left to their
``default_factory`` -- doing so would hand back a config with brand new,
empty caches on every call, silently defeating cross-call IP-info
caching and aggregate-report dedup. Instead, the module-default caches
(:data:`IP_ADDRESS_CACHE`, :data:`SEEN_AGGREGATE_REPORT_IDS`,
:data:`REVERSE_DNS_MAP`) are injected explicitly, so kwargs-style calls
keep observing and mutating the same shared caches they always have.
``dataclasses.replace`` is deliberately not used here: it would need an
existing ``ParserConfig`` to start from, and there isn't one on the
kwargs path -- this function's job is to build the first one.
``psl_overrides_path`` / ``psl_overrides_url`` have no corresponding
keyword arguments on the public functions (see AGENTS.md's guidance on
justifying new config options), so they stay ``None`` on this path; they
are only ever set via an explicitly constructed ``config=``.
"""
if config is not None:
return config
return ParserConfig(
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=float(normalize_timespan_threshold_hours),
ip_address_cache=IP_ADDRESS_CACHE,
seen_aggregate_report_ids=SEEN_AGGREGATE_REPORT_IDS,
reverse_dns_map=REVERSE_DNS_MAP,
)
def _exc_origin(error: BaseException) -> str:
"""Returns a ``" (raised at <file>:<line>)"`` suffix pointing at where an
unexpected exception actually originated, but only when the parsedmarc
logger is at ``DEBUG`` level. Returns ``""`` otherwise, so normal output is
unchanged.
The deepest traceback frame is the line that raised, which is the useful
breadcrumb when a catch-all ``except Exception`` turns an unforeseen error
into a generic ``Invalid*Report`` -- without it, the message says *what*
failed but not *where*.
"""
if not logger.isEnabledFor(logging.DEBUG):
return ""
frames = traceback.extract_tb(error.__traceback__)
if not frames:
return ""
last = frames[-1]
return f" (raised at {last.filename}:{last.lineno})"
def _text(value: Any) -> str | None:
"""Unwrap a value parsed by xmltodict that may be a dict.
RFC 9990 changed several aggregate-report elements (extra_contact_info,
error, comment, human_result) to type ``langAttrString``, a string with
an optional ``lang`` attribute. xmltodict parses the report without
attributes (``xml_attribs=False``), so such an element arrives as a
string; a dict now arises only from child elements, and gives its
``#text``. Returns ``None`` for unset values and leaves other scalar
shapes untouched.
"""
if value is None:
return None
if isinstance(value, dict):
text = value.get("#text")
return None if text is None else str(text)
return value
def _normalize_result_word(value: Any) -> Any:
"""Lowercase a reporter-supplied enum word; RFC 7489 Appendix C and
RFC 9990 define result/disposition types as lowercase tokens. Its
callers pass strings or ``None`` (``_scalar()`` has already run), and a
non-string passes through unchanged.
"""
return value.lower() if isinstance(value, str) else value
def _bucket_interval_by_day(
begin: datetime,
end: datetime,
total_count: int,
) -> list[dict[str, Any]]:
"""
Split the interval [begin, end) into daily buckets and distribute
`total_count` proportionally across those buckets.
The function:
1. Identifies each calendar day touched by [begin, end)
2. Computes how many seconds of the interval fall into each day
3. Assigns counts in proportion to those overlaps
4. Ensures the final counts sum exactly to total_count
Args:
begin: timezone-aware datetime, inclusive start of interval
end: timezone-aware datetime, exclusive end of interval
total_count: number of messages to distribute
Returns:
A list of dicts like:
{
"begin": datetime,
"end": datetime,
"count": int
}
"""
# --- Input validation ----------------------------------------------------
if begin > end:
raise ValueError("begin must be earlier than end")
if begin.tzinfo is None or end.tzinfo is None:
raise ValueError("begin and end must be timezone-aware")
if begin.tzinfo is not end.tzinfo:
raise ValueError("begin and end must have the same tzinfo")
if total_count < 0:
raise ValueError("total_count must be non-negative")
# --- Short-circuit trivial cases -----------------------------------------
interval_seconds = (end - begin).total_seconds()
if interval_seconds <= 0 or total_count == 0:
return []
tz: tzinfo = begin.tzinfo
# --- Step 1: Determine all calendar days touched by [begin, end) ----------
#
# For example:
# begin = Jan 1 12:00
# end = Jan 3 06:00
#
# We need buckets for:
# Jan 1 12:00 → Jan 2 00:00
# Jan 2 00:00 → Jan 3 00:00
# Jan 3 00:00 → Jan 3 06:00
#
# Start at midnight on the day of `begin`.
day_cursor = datetime(begin.year, begin.month, begin.day, tzinfo=tz)
# If `begin` is earlier on that day (e.g. 10:00), we want that midnight.
# If `begin` is past that midnight (e.g. 00:30), this is correct.
# If `begin` is BEFORE that midnight (rare unless tz shifts), adjust:
if day_cursor > begin:
day_cursor -= timedelta(days=1)
day_buckets: list[dict[str, Any]] = []
while day_cursor < end:
day_start = day_cursor
day_end = day_cursor + timedelta(days=1)
# Overlap between [begin, end) and this day
overlap_start = max(begin, day_start)
overlap_end = min(end, day_end)
overlap_seconds = (overlap_end - overlap_start).total_seconds()
if overlap_seconds > 0:
day_buckets.append(
{
"begin": overlap_start,
"end": overlap_end,
"seconds": overlap_seconds,
}
)
day_cursor = day_end
# --- Step 2: Pro-rate counts across buckets -------------------------------
#
# Compute the exact fractional count for each bucket:
# bucket_fraction = bucket_seconds / interval_seconds
# bucket_exact = total_count * bucket_fraction
#
# Then apply a "largest remainder" rounding strategy to ensure the sum
# equals exactly total_count.
exact_values: list[float] = [
(b["seconds"] / interval_seconds) * total_count for b in day_buckets
]
floor_values: list[int] = [int(x) for x in exact_values]
fractional_parts: list[float] = [x - int(x) for x in exact_values]
# How many counts do we still need to distribute after flooring?
remainder = total_count - sum(floor_values)
# Sort buckets by descending fractional remainder
indices_by_fraction = sorted(
range(len(day_buckets)),
key=lambda i: fractional_parts[i],
reverse=True,
)
# Start with floor values
final_counts = floor_values[:]
# Add +1 to the buckets with the largest fractional parts
for idx in indices_by_fraction[:remainder]:
final_counts[idx] += 1
# --- Step 3: Build the final per-day result list -------------------------
results: list[dict[str, Any]] = []
for bucket, count in zip(day_buckets, final_counts):
if count > 0:
results.append(
{
"begin": bucket["begin"],
"end": bucket["end"],
"count": count,
}
)
return results
def _append_parsed_record(
parsed_record: dict[str, Any],
records: list[dict[str, Any]],
begin_dt: datetime,
end_dt: datetime,
normalize: bool,
) -> None:
"""
Append a parsed DMARC record either unchanged or normalized.
Args:
parsed_record: The record returned by _parse_report_record().
records: Accumulating list of output records.
begin_dt: Report-level begin datetime (UTC).
end_dt: Report-level end datetime (UTC).
normalize: Whether this report exceeded the allowed timespan
and should be normalized per-day.
"""
if not normalize:
parsed_record["normalized_timespan"] = False
parsed_record["interval_begin"] = begin_dt.strftime("%Y-%m-%d %H:%M:%S")
parsed_record["interval_end"] = end_dt.strftime("%Y-%m-%d %H:%M:%S")
records.append(parsed_record)
return
# Normalization path: break record into daily buckets
total_count = int(parsed_record.get("count", 0))
buckets = _bucket_interval_by_day(begin_dt, end_dt, total_count)
if not buckets:
return
for part_index, bucket in enumerate(buckets):
new_rec = parsed_record.copy()
new_rec["count"] = bucket["count"]
new_rec["normalized_timespan"] = True
new_rec["interval_begin"] = bucket["begin"].strftime("%Y-%m-%d %H:%M:%S")
new_rec["interval_end"] = bucket["end"].strftime("%Y-%m-%d %H:%M:%S")
records.append(new_rec)
def _scalar(value: Any) -> str | None:
"""Return the string an element the schema types as a string should hold.
A string is returned as it is. A dict (an element with child elements,
possibly beside text) gives its ``#text`` or ``None``; a list (a
repeated element) or any other value gives ``None``. Without this a
child element reached Elasticsearch and OpenSearch as an object and
mapped the field as one (GHSA-qpcg-29vr-jwm3).
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, dict):
text = value.get("#text")
return text if isinstance(text, str) else None
return None
def _scalar_fields(mapping: Any, keys: tuple[str, ...]) -> None:
"""Replace each of ``keys`` present in ``mapping`` with its
:func:`_scalar` value, in place; a ``mapping`` that is not a dict is
left to the caller's own error handling."""
if not isinstance(mapping, dict):
return
for key in keys:
if key in mapping:
mapping[key] = _scalar(mapping[key])
_REPORT_METADATA_SCALARS = (
"org_name",
"email",
"extra_contact_info",
"report_id",
"generator",
)
_POLICY_PUBLISHED_SCALARS = (
"domain",
"adkim",
"aspf",
"p",
"sp",
"pct",
"fo",
"np",
"testing",
"discovery_method",
)
def _scalarize_report(report: dict[str, Any]) -> None:
"""Make the report-level fields the schema types as strings strings or
``None``, in place: ``version``, the ``report_metadata`` strings and
every ``policy_published`` field of the RFC 7489 and RFC 9990 schemas
(other children are not copied to the output). Records are handled by
:func:`_parse_report_record`. Anything that is not a dict is left to
the caller's own error handling."""
if not isinstance(report, dict):
return
_scalar_fields(report, ("version",))
_scalar_fields(report.get("report_metadata"), _REPORT_METADATA_SCALARS)
policies = report.get("policy_published")
for policy in policies if isinstance(policies, list) else [policies]:
_scalar_fields(policy, _POLICY_PUBLISHED_SCALARS)
def _strip_zone_id(value: str, field_name: str) -> str:
"""Return an IP address without its IPv6 zone ID, if it has one.
A zone ID (``fe80::1%eth0``) is local to the reporting host, and the
PostgreSQL ``inet`` type cannot hold one (GHSA-fxr7-7h9q-v949). ``%``
appears in no other form of an IP address, so everything from the
first ``%`` is removed (``fe80::1%25eth0`` and ``fe80::1%`` also give
``fe80::1``), and a warning naming the field is logged. The remaining
text is not checked here; the caller's own handling of an invalid IP
address applies to it.
Args:
value: The address, as the report gives it
field_name: The report field that gave it, for the warning
Returns:
str: ``value`` up to its first ``%``
"""
address, sep, _zone = value.partition("%")
if sep:
logger.warning(
f"Removed the IPv6 zone ID from {field_name}: {sanitize_for_log(value)}"
)
return address
@dataclass
class _ReverseDNSBudget:
"""Per-report state that bounds the time an aggregate report spends in
reverse DNS lookups (GHSA-rwfh-ww9c-jm9m).
Attributes:
seconds_left: Seconds of failed or slow lookups the report may still
spend (``MAX_FAILED_REVERSE_DNS_SECONDS_PER_REPORT``)
total_seconds_left: Seconds of lookups of any kind the report may
still spend (``MAX_REVERSE_DNS_SECONDS_PER_REPORT``)
memo: Source information already produced in this report, by
lowercase IP address, so each distinct IP is looked up at most
once per report
exhausted: Whether either budget is spent; the report's remaining
IPs are then enriched from the local IP database and reverse
DNS map only
"""
seconds_left: float = field(
default_factory=lambda: MAX_FAILED_REVERSE_DNS_SECONDS_PER_REPORT
)
total_seconds_left: float = field(
default_factory=lambda: MAX_REVERSE_DNS_SECONDS_PER_REPORT
)
memo: dict[str, IPAddressInfo] = field(default_factory=dict)
exhausted: bool = False
_REVERSE_DNS_BUDGET_FALLBACK = (
"; the remaining records are enriched from the local IP database and "
"reverse DNS map only"
)
def _enrich_record_source(
source_ip: str, config: ParserConfig, budget: _ReverseDNSBudget
) -> IPAddressInfo:
"""Return the source information for one record's ``source_ip``.
A record's source IP is chosen by the report's sender, and a reverse
zone that never answers costs the whole DNS lifetime (8 s with the
default nameservers), so the time of every online lookup is charged to
``budget``. The time of a lookup that returns no PTR, or that takes at
least ``SLOW_REVERSE_DNS_SECONDS`` even though it found one (the sender
may control the reverse zone and answer every query just before the
timeout), is charged to the failed-or-slow budget; the time of every
lookup, however fast, is charged to the total budget as well, because a
zone that answers just under the slow threshold is otherwise never
charged and the number of records is not bounded. A lookup answered
from the IP address cache is charged too, but takes microseconds. Once
either budget is spent, one warning names the limit reached, and the
remaining IPs are enriched from the local IP database and reverse DNS
map only; those results are not cached, because the IP may well have a
PTR. An IP already seen in this report is answered from ``budget.memo``
and not charged.
"""
ip = source_ip.lower()
if ip in budget.memo:
return budget.memo[ip]
common: dict[str, Any] = dict(
ip_db_path=config.ip_db_path,
always_use_local_files=config.always_use_local_files,
reverse_dns_map_path=config.reverse_dns_map_path,
reverse_dns_map_url=config.reverse_dns_map_url,
reverse_dns_map=config.reverse_dns_map,
nameservers=config.nameservers,
timeout=config.dns_timeout,
retries=config.dns_retries,
psl_overrides_path=config.psl_overrides_path,
psl_overrides_url=config.psl_overrides_url,
)
if budget.exhausted or config.offline:
result = get_ip_address_info(
ip,
offline=True,
cache=None if budget.exhausted else config.ip_address_cache,
**common,
)
else:
start = time.monotonic()
result = get_ip_address_info(
ip, offline=False, cache=config.ip_address_cache, **common
)
elapsed = time.monotonic() - start
budget.total_seconds_left -= elapsed
if result["reverse_dns"] is None or elapsed >= SLOW_REVERSE_DNS_SECONDS:
budget.seconds_left -= elapsed
if budget.seconds_left <= 0:
budget.exhausted = True
logger.warning(
"Reverse DNS lookups for this report failed or were slow for "
f"{MAX_FAILED_REVERSE_DNS_SECONDS_PER_REPORT:.0f} seconds"
+ _REVERSE_DNS_BUDGET_FALLBACK
)
elif budget.total_seconds_left <= 0:
budget.exhausted = True
logger.warning(
"Reverse DNS lookups for this report took more than "
f"{MAX_REVERSE_DNS_SECONDS_PER_REPORT:.0f} seconds"
+ _REVERSE_DNS_BUDGET_FALLBACK
)
budget.memo[ip] = result
return result
def _parse_report_record(
record: dict[str, Any],
*,
config: ParserConfig,
is_rfc_9990: bool = False,
dns_budget: _ReverseDNSBudget | None = None,
) -> dict[str, Any]:
"""
Converts a record from a DMARC aggregate report into a more consistent
format
Args:
record (dict): The record to convert
config (ParserConfig): Parsing and enrichment options, plus caches
is_rfc_9990 (bool): Whether the enclosing report was detected as
RFC 9990-shaped, for RFC 9990-aware validation warnings
dns_budget (_ReverseDNSBudget | None): The enclosing report's
reverse DNS budget; ``None`` gives the record a budget of its own
Returns:
dict: The converted record
Raises:
ValueError: If the source IP is empty or not an IP address (after
its IPv6 zone ID, if any, is removed), or the count is not an
integer between 0 and ``MAX_MESSAGE_COUNT``
"""
record = record.copy()
new_record: dict[str, Any] = {}
# Fields the schema types as strings become strings or None
# (GHSA-qpcg-29vr-jwm3); see _scalar().
_scalar_fields(record["row"], ("source_ip",))
if record["row"]["source_ip"] is None:
raise ValueError("Source IP address is empty")
source_ip = _strip_zone_id(record["row"]["source_ip"], "source_ip")
# A <count> with no text of its own (child elements only, or repeated)
# becomes "", so int() raises the ValueError of a non-numeric count.
count = int(_scalar(record["row"]["count"]) or "")
# A count no output's 32-bit integer column can hold is rejected here,
# not by the output (GHSA-fxr7-7h9q-v949), and before the reverse DNS
# lookup, which a record that is dropped anyway should not wait on.
if not 0 <= count <= MAX_MESSAGE_COUNT:
raise ValueError(f"count {count} is outside 0..{MAX_MESSAGE_COUNT}")
if dns_budget is None:
dns_budget = _ReverseDNSBudget()
new_record["source"] = _enrich_record_source(source_ip, config, dns_budget)
new_record["count"] = count
policy_evaluated = record["row"]["policy_evaluated"].copy()
_scalar_fields(policy_evaluated, ("disposition", "dkim", "spf"))
new_policy_evaluated: dict[str, Any] = {
"disposition": "none",
"dkim": "fail",
"spf": "fail",
"policy_override_reasons": [],
}
if "disposition" in policy_evaluated:
new_policy_evaluated["disposition"] = _normalize_result_word(
policy_evaluated["disposition"]
)
for key in ("dkim", "spf"):
if key in policy_evaluated:
new_policy_evaluated[key] = _normalize_result_word(policy_evaluated[key])
reasons = []
spf_aligned = (
policy_evaluated["spf"] is not None
and policy_evaluated["spf"].lower() == "pass"
)
dkim_aligned = (
policy_evaluated["dkim"] is not None
and policy_evaluated["dkim"].lower() == "pass"
)
dmarc_aligned = spf_aligned or dkim_aligned
new_record["alignment"] = dict()
new_record["alignment"]["spf"] = spf_aligned
new_record["alignment"]["dkim"] = dkim_aligned
new_record["alignment"]["dmarc"] = dmarc_aligned
if "reason" in policy_evaluated:
if type(policy_evaluated["reason"]) is list:
reasons = policy_evaluated["reason"]
else:
reasons = [policy_evaluated["reason"]]
normalized_reasons = []
for reason in reasons:
# `comment` and `type` are strings in both schemas, but a sender can
# give them child elements (a dict) or repeat them (a list), and the
# CSV writer joins them as strings (GHSA-4272-4r8g-6c5h). They follow
# the rule of every other string field: see _scalar().
reason_type = _scalar(reason.get("type"))
normalized_reasons.append(
{"type": reason_type, "comment": _scalar(reason.get("comment"))}
)
if is_rfc_9990 and reason_type in RFC_7489_REMOVED_POLICY_OVERRIDE_TYPES:
logger.warning(
"Policy override reason type %r was removed in RFC 9990; "
"expected one of %s",
reason_type,
sorted(RFC_9990_POLICY_OVERRIDE_TYPES),
)
elif (
is_rfc_9990
and reason_type is not None
and reason_type not in RFC_9990_POLICY_OVERRIDE_TYPES
):
logger.warning(
"Unknown policy override reason type %r per RFC 9990; "
"expected one of %s",
reason_type,
sorted(RFC_9990_POLICY_OVERRIDE_TYPES),
)
new_policy_evaluated["policy_override_reasons"] = normalized_reasons
new_record["policy_evaluated"] = new_policy_evaluated
raw_identifiers = (
record["identities"] if "identities" in record else record["identifiers"]
)
new_record["identifiers"] = {
key: _scalar(value)
for key, value in raw_identifiers.items()
if key in ("header_from", "envelope_from", "envelope_to")
}
new_record["auth_results"] = {"dkim": [], "spf": []}
if type(new_record["identifiers"]["header_from"]) is str:
lowered_from = new_record["identifiers"]["header_from"].lower()
else:
lowered_from = ""
new_record["identifiers"]["header_from"] = lowered_from
if isinstance(record["auth_results"], dict):
auth_results = record["auth_results"].copy()
if "spf" not in auth_results:
auth_results["spf"] = []
if "dkim" not in auth_results:
auth_results["dkim"] = []
else:
auth_results = new_record["auth_results"].copy()
if not isinstance(auth_results["dkim"], list):
auth_results["dkim"] = [auth_results["dkim"]]
for result in auth_results["dkim"]:
_scalar_fields(result, ("domain", "selector", "result", "human_result"))
if "domain" in result and result["domain"] is not None:
new_result: dict[str, Any] = {"domain": result["domain"]}
if "selector" in result and result["selector"] is not None:
new_result["selector"] = result["selector"]
else:
if is_rfc_9990:
logger.warning(
"DKIM auth result for %r is missing the 'selector' "
"element, which is REQUIRED by RFC 9990",
result["domain"],
)
new_result["selector"] = "none"
if "result" in result and result["result"] is not None:
new_result["result"] = _normalize_result_word(result["result"])
else:
new_result["result"] = "none"
new_result["human_result"] = _text(result.get("human_result"))
new_record["auth_results"]["dkim"].append(new_result)
if not isinstance(auth_results["spf"], list):
auth_results["spf"] = [auth_results["spf"]]
for result in auth_results["spf"]:
_scalar_fields(result, ("domain", "scope", "result", "human_result"))
if "domain" in result and result["domain"] is not None:
new_result: dict[str, Any] = {"domain": result["domain"]}
if "scope" in result and result["scope"] is not None:
new_result["scope"] = result["scope"]
else:
new_result["scope"] = "mfrom"
if "result" in result and result["result"] is not None:
new_result["result"] = _normalize_result_word(result["result"])
else:
new_result["result"] = "none"
new_result["human_result"] = _text(result.get("human_result"))
new_record["auth_results"]["spf"].append(new_result)
# Backfill envelope_from from the last SPF result's domain when the
# reporter omitted the identifier or sent it empty.
if new_record["identifiers"].get("envelope_from") is None:
envelope_from = None
if len(auth_results["spf"]) > 0:
spf_result = auth_results["spf"][-1]
if "domain" in spf_result:
envelope_from = spf_result["domain"]
if envelope_from is not None:
envelope_from = str(envelope_from).lower()
new_record["identifiers"]["envelope_from"] = envelope_from
envelope_to = None
if "envelope_to" in new_record["identifiers"]:
envelope_to = new_record["identifiers"]["envelope_to"]
del new_record["identifiers"]["envelope_to"]
new_record["identifiers"]["envelope_to"] = envelope_to
return new_record
def _non_blank(value: Any) -> Any:
"""Treat ``None`` or a blank/whitespace-only string as absent.
Some SMTP TLS (RFC 8460) reporters send an empty string for an optional
failure-details field (e.g. ``sending-mta-ip``/``receiving-ip`` on a
``sts-policy-fetch-error``) instead of omitting the key. An empty string
is not a valid IP address, so passing it through crashed the PostgreSQL
save (``INET`` columns) and would also have crashed the Elasticsearch
and OpenSearch saves: their ``Ip()`` fields' validation
(``elasticsearch.dsl``/``opensearchpy``'s ``Document.save()``) rejects
an empty string with a bare ``ValueError`` (issue #915). Non-string,
non-blank values pass through unchanged.
"""
if value is None:
return None
if isinstance(value, str) and value.strip() == "":
return None
return value
# RFC 8460 §4.4 gives every SMTP TLS report date as an RFC 3339 date-time,
# which is at most about 35 characters even with a long fractional second.
# dateutil's parser is super-linear on a long run of digits (12.4 s for
# 1,000,000 of them, Python 3.14), so a longer value is rejected unparsed.
_SMTP_TLS_MAX_DATETIME_LENGTH = 64
def _smtp_tls_string(value: Any, field: str) -> str:
"""Return ``value`` if it is a string, as RFC 8460 §4.4 requires of
``field``; raise ``InvalidSMTPTLSReport`` otherwise."""
if not isinstance(value, str):
raise InvalidSMTPTLSReport(
f"{field} must be a string, not {type(value).__name__}"
)
_smtp_tls_check_utf8(value, field)
return value
def _smtp_tls_check_utf8(value: str, field: str) -> None:
"""Raise ``InvalidSMTPTLSReport`` when ``value`` holds a lone surrogate
(JSON ``"\\ud800"``). RFC 8460 §4 reports are I-JSON, whose RFC 7493
§2.1 forbids surrogate code points, and the outputs cannot encode one."""
try:
value.encode("utf-8")
except UnicodeEncodeError as error:
raise InvalidSMTPTLSReport(f"{field} is not valid UTF-8 text") from error
def _smtp_tls_string_list(value: Any, field: str) -> list[str]:
"""Return ``value`` if it is a list of strings (RFC 8460 §4.4's "JSON
array of strings"); raise ``InvalidSMTPTLSReport`` otherwise."""
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise InvalidSMTPTLSReport(f"{field} must be a list of strings")
for item in value:
_smtp_tls_check_utf8(item, field)
return value
def _smtp_tls_mx_host_patterns(value: Any, field: str) -> list[str]:
"""Return a policy's MX host patterns as a list of strings.
RFC 8460 §4.4 describes the value as "a JSON array of strings", but its
Appendix B example gives one pattern as a plain string, so a string
becomes a one-element list. ``null`` and an empty string give an empty
list; any other type raises ``InvalidSMTPTLSReport``.
"""
if value is None or value == "":
return []
if isinstance(value, str):
return [_smtp_tls_string(value, field)]
return _smtp_tls_string_list(value, field)
def _smtp_tls_count(value: Any, field: str) -> int | None:
"""Return ``value`` if it is null or an integer from 0 to
``MAX_MESSAGE_COUNT``.
RFC 8460 §4.4 encodes each session count as "an integer, encoded as a
JSON number". ``null`` is tolerated as an unreported count. A JSON
``true``/``false`` is rejected even though ``bool`` subclasses ``int``.
A count outside ``0..MAX_MESSAGE_COUNT`` is rejected because the
PostgreSQL output stores counts as 32-bit integers
(GHSA-fxr7-7h9q-v949). Raises ``InvalidSMTPTLSReport`` otherwise.
"""
if value is None:
return value
if not isinstance(value, int) or isinstance(value, bool):
raise InvalidSMTPTLSReport(
f"{field} must be an integer, not {type(value).__name__}"
)
if not 0 <= value <= MAX_MESSAGE_COUNT:
raise InvalidSMTPTLSReport(f"{field} must be between 0 and {MAX_MESSAGE_COUNT}")
return value
def _smtp_tls_datetime(value: Any, field: str) -> str:
"""Return ``value`` if it is a date-time string every output can convert.
RFC 8460 §4.4 gives ``start-datetime`` and ``end-datetime`` as strings in
RFC 3339 format. The value is checked with the conversions the
Elasticsearch, OpenSearch and S3 outputs (a UTC ``datetime``) and the
Splunk output (a UNIX timestamp) make later. dateutil also accepts a
partial date such as ``"2016"``, which PostgreSQL may still reject; that
failure is raised as ``PostgreSQLRejectedReport``, which skips the
report in the PostgreSQL output only (an output error with
``fail_on_output_error``). A ``start-datetime`` more than
``MAX_REPORT_FUTURE_DAYS`` days in the future is also rejected, because
it names the dated index (#947); ``end-datetime`` is not checked. Raises
``InvalidSMTPTLSReport`` otherwise.
"""
value = _smtp_tls_string(value, field)
if len(value) > _SMTP_TLS_MAX_DATETIME_LENGTH:
raise InvalidSMTPTLSReport(
f"{field} is longer than {_SMTP_TLS_MAX_DATETIME_LENGTH} characters"
)
try:
moment = human_timestamp_to_datetime(value, to_utc=True)
human_timestamp_to_unix_timestamp(value)
except (ValueError, OverflowError) as e:
raise InvalidSMTPTLSReport(
f"{field} is not a valid date-time: {sanitize_for_log(value)}"
) from e
if field == "start-datetime":
try:
_check_not_future(moment, field)
except ValueError as error:
raise InvalidSMTPTLSReport(str(error)) from error
return value
def _parse_smtp_tls_failure_details(failure_details: dict[str, Any]):
try:
new_failure_details: dict[str, Any] = {
"result_type": _smtp_tls_string(
failure_details["result-type"], "result-type"
),
"failed_session_count": _smtp_tls_count(
failure_details["failed-session-count"], "failed-session-count"
),
}
optional_fields = [
("sending-mta-ip", "sending_mta_ip"),
("receiving-ip", "receiving_ip"),
("receiving-mx-hostname", "receiving_mx_hostname"),
("receiving-mx-helo", "receiving_mx_helo"),
("failure-reason-code", "failure_reason_code"),
]
for src_key, dest_key in optional_fields:
value = _non_blank(failure_details.get(src_key))
if value is not None:
new_failure_details[dest_key] = _smtp_tls_string(value, src_key)
# RFC 8460 §4.4 defines both as the IP address of the sending or
# receiving MTA, and both are optional. Drop a value that is not an
# IP address, which the PostgreSQL inet column and the
# Elasticsearch/OpenSearch ip mappings reject (GHSA-fxr7-7h9q-v949).
# An IPv6 zone ID (fe80::1%eth0) is local to the reporting host and
# the inet type cannot hold one, and ipaddress.ip_address() accepts
# it, so such a value is dropped as well. The aggregate and failure
# paths strip the zone ID instead (_strip_zone_id()), because the
# source IP is required there; these fields are optional, so
# dropping one loses nothing a report needs.
for src_key, dest_key in (
("sending-mta-ip", "sending_mta_ip"),
("receiving-ip", "receiving_ip"),
):
value = new_failure_details.get(dest_key)
if value is None:
continue
try:
if "%" in value:
raise ValueError("zone ID")
ipaddress.ip_address(value)
except ValueError:
logger.warning(
f"Ignoring {src_key} that is not an IP address: "
f"{sanitize_for_log(value)}"
)
del new_failure_details[dest_key]
# RFC 8460 §4.4 names the JSON key "additional-information" (its
# value is described as an "additional-info-uri"). Some reporters
# instead send the non-RFC key "additional-info-uri" directly; fall
# back to it for backward compatibility when the RFC key is absent
# or blank.
additional_info_uri = _non_blank(failure_details.get("additional-information"))
if additional_info_uri is None:
additional_info_uri = _non_blank(failure_details.get("additional-info-uri"))
if additional_info_uri is not None:
new_failure_details["additional_info_uri"] = _smtp_tls_string(
additional_info_uri, "additional-information"
)
return new_failure_details
except KeyError as e:
raise InvalidSMTPTLSReport(
f"Missing required failure details field: {e}"
) from e
except Exception as e:
raise InvalidSMTPTLSReport(str(e) + _exc_origin(e)) from e
def _parse_smtp_tls_report_policy(policy: dict[str, Any]):
policy_types = ["tlsa", "sts", "no-policy-found"]
try:
policy_domain = _smtp_tls_string(
policy["policy"]["policy-domain"], "policy-domain"
)
policy_type = _smtp_tls_string(policy["policy"]["policy-type"], "policy-type")
failure_details = []
if policy_type not in policy_types:
raise InvalidSMTPTLSReport(
f"Invalid policy type {sanitize_for_log(str(policy_type))}"
)
new_policy: dict[str, Any] = {
"policy_domain": policy_domain,
"policy_type": policy_type,
}
if policy["policy"].get("policy-string") is not None:
policy_strings = _smtp_tls_string_list(
policy["policy"]["policy-string"], "policy-string"
)
if len(policy_strings) > 0:
new_policy["policy_strings"] = policy_strings
# RFC 8460 §4.4 keys the policy's MX host patterns "mx-host";
# "mx-host-pattern" names its value type, not a key, but was read as
# the key before and is kept as a fallback (issue #942).
mx_host_patterns = _smtp_tls_mx_host_patterns(
policy["policy"].get("mx-host"), "mx-host"
)
if not mx_host_patterns:
mx_host_patterns = _smtp_tls_mx_host_patterns(
policy["policy"].get("mx-host-pattern"), "mx-host-pattern"
)
if mx_host_patterns:
new_policy["mx_host_patterns"] = mx_host_patterns
new_policy["successful_session_count"] = _smtp_tls_count(
policy["summary"]["total-successful-session-count"],
"total-successful-session-count",
)
new_policy["failed_session_count"] = _smtp_tls_count(
policy["summary"]["total-failure-session-count"],
"total-failure-session-count",
)
if "failure-details" in policy:
if not isinstance(policy["failure-details"], list) or not all(
isinstance(details, dict) for details in policy["failure-details"]
):
raise InvalidSMTPTLSReport("failure-details must be a list of objects")
for details in policy["failure-details"]:
failure_details.append(_parse_smtp_tls_failure_details(details))
new_policy["failure_details"] = failure_details
return new_policy
except KeyError as e:
raise InvalidSMTPTLSReport(f"Missing required policy field: {e}") from e
except Exception as e:
raise InvalidSMTPTLSReport(str(e) + _exc_origin(e)) from e
def parse_smtp_tls_report_json(report: str | bytes) -> SMTPTLSReport:
"""Parses and validates an SMTP TLS report"""
required_fields = [
"organization-name",
"date-range",
"contact-info",
"report-id",
"policies",
]
if _exceeds_size_cap(report):
# json.loads() holds the whole document at several times its size
# (GHSA-q7gm-8cj9-94hr).
raise InvalidSMTPTLSReport(_size_cap_text(report))
try:
if isinstance(report, bytes):
report = report.decode("utf-8", errors="replace")
policies = []
report_dict = json.loads(report)
for required_field in required_fields:
if required_field not in report_dict:
raise Exception(f"Missing required field: {required_field}]")
if not isinstance(report_dict["policies"], list):
policies_type = type(report_dict["policies"])
raise InvalidSMTPTLSReport(f"policies must be a list, not {policies_type}")
for policy in report_dict["policies"]:
policies.append(_parse_smtp_tls_report_policy(policy))
# RFC 8460 §4.4 gives contact-info as one string; some reporters
# send a list of them, which is kept as it is.
contact_info = report_dict["contact-info"]
if isinstance(contact_info, str):
contact_info = _smtp_tls_string(contact_info, "contact-info")
else:
contact_info = _smtp_tls_string_list(contact_info, "contact-info")
new_report: SMTPTLSReport = {
"organization_name": _smtp_tls_string(
report_dict["organization-name"], "organization-name"
),
"begin_date": _smtp_tls_datetime(
report_dict["date-range"]["start-datetime"], "start-datetime"
),
"end_date": _smtp_tls_datetime(
report_dict["date-range"]["end-datetime"], "end-datetime"
),
"contact_info": contact_info,
"report_id": _smtp_tls_string(report_dict["report-id"], "report-id"),
"policies": policies,
}
return new_report
except KeyError as e:
raise InvalidSMTPTLSReport(f"Missing required field: {e}") from e
except Exception as e:
raise InvalidSMTPTLSReport(str(e) + _exc_origin(e)) from e
# Characters that make a spreadsheet application read a cell as a formula.
_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: Any) -> Any:
"""Makes a string CSV field safe to write and to open in a spreadsheet.
Two things happen to a string, in this order:
1. NUL characters are stripped. Python 3.10's ``csv`` writer raised
``_csv.Error: need to escape, but no escapechar set`` on any field
containing NUL (CPython issue 97503, fixed in 3.11; the floor is now
3.11), and NUL is still dropped to match
``get_filename_safe_string``.
2. A string starting with ``=``, ``+``, ``-``, ``@``, tab, or carriage
return gets a leading ``'``. Spreadsheet applications evaluate such
a cell as a formula, and report text fields come from untrusted
mail, so a reporter could otherwise plant a formula like
``=HYPERLINK(...)`` in the output (CWE-1236). The apostrophe is the
OWASP CSV Injection recommendation, and makes the application
treat the cell as text:
https://owasp.org/www-community/attacks/CSV_Injection
Non-string values (ASN integers, counts) are returned unchanged.
"""
if isinstance(value, str):
value = value.replace("\x00", "")
if value.startswith(_CSV_FORMULA_PREFIXES):
return "'" + value
return value
return value
def parsed_smtp_tls_reports_to_csv_rows(
reports: SMTPTLSReport | list[SMTPTLSReport],
) -> list[dict[str, Any]]:
"""Converts one or more parsed SMTP TLS reports into a list of
single-layer dict objects suitable for use in a CSV"""
if isinstance(reports, dict):
reports = [reports]
rows = []
for report in reports:
common_fields: dict[str, Any] = {
"organization_name": report["organization_name"],
"begin_date": report["begin_date"],
"end_date": report["end_date"],
"report_id": report["report_id"],
}
record: dict[str, Any] = common_fields.copy()
for policy in report["policies"]:
if "policy_strings" in policy:
record["policy_strings"] = "|".join(policy["policy_strings"])
if "mx_host_patterns" in policy:
record["mx_host_patterns"] = "|".join(policy["mx_host_patterns"])
successful_record = record.copy()
successful_record["policy_domain"] = policy["policy_domain"]
successful_record["policy_type"] = policy["policy_type"]
successful_record["successful_session_count"] = policy[
"successful_session_count"
]
successful_record["failed_session_count"] = policy["failed_session_count"]
rows.append(successful_record)
if "failure_details" in policy:
for failure_details in policy["failure_details"]:
failure_record = record.copy()
for key in failure_details.keys():
failure_record[key] = failure_details[key]
rows.append(failure_record)
return rows
def parsed_smtp_tls_reports_to_csv(
reports: SMTPTLSReport | list[SMTPTLSReport],
) -> str:
"""
Converts one or more parsed SMTP TLS reports to flat CSV format, including
headers
Args:
reports: A parsed SMTP TLS report or list of parsed SMTP TLS reports
Returns:
str: Parsed SMTP TLS report data in flat CSV format, including headers
"""
fields = [
"organization_name",
"begin_date",
"end_date",
"report_id",
"result_type",
"successful_session_count",
"failed_session_count",
"policy_domain",
"policy_type",
"policy_strings",
"mx_host_patterns",
"sending_mta_ip",
"receiving_ip",
"receiving_mx_hostname",
"receiving_mx_helo",
"additional_info_uri",
"failure_reason_code",
]
csv_file_object = StringIO(newline="\n")
writer = DictWriter(csv_file_object, fields)
writer.writeheader()
rows = parsed_smtp_tls_reports_to_csv_rows(reports)
for row in rows:
writer.writerow({key: _csv_safe(value) for key, value in row.items()})
csv_file_object.flush()
return csv_file_object.getvalue()
# No field of an aggregate report carries an attribute parsedmarc keeps, and
# an attribute xmltodict kept (<p a="1">none</p> as {"@a": "1", "#text":
# "none"}) would reach Elasticsearch or OpenSearch as an object and map the
# field as one (GHSA-qpcg-29vr-jwm3). With xml_attribs=False xmltodict builds
# no attribute keys, while expat still hands the attributes to the start
# handler, so _LimitedExpatParser counts them.
def _parse_limited_xml(xml: str) -> Any:
"""Parse ``xml`` with xmltodict through the limited expat parser, without
attributes, and raise ``InvalidAggregateReport`` when it goes over
``MAX_XML_DEPTH`` or ``MAX_XML_NODES``."""
try:
return xmltodict.parse(xml, expat=_NoDoctypeExpat, xml_attribs=False)
except _XMLLimitExceeded as error:
raise InvalidAggregateReport(f"Invalid XML: {error}") from error
def _recover_xml(xml: str) -> str:
"""Return ``xml`` repaired by lxml's recovery parser, for a document
expat rejected: ``""`` when nothing is recoverable, ``"<a/>"`` when the
recovery itself fails. Raises ``InvalidAggregateReport`` for a document
over the node limit or holding a DOCTYPE."""
has_doctype = False
try:
tree = _parse_xml_with_recovery(xml.encode("utf-8"))
root = tree.getroot()
if root is None:
# Nothing recoverable; the second parse reports it as
# invalid XML.
return ""
# The first parse can fail before expat reaches a DOCTYPE (e.g. on
# a malformed XML declaration, which is only rewritten later), so
# the recovered tree gets the same DOCTYPE check, made before
# serializing. The rejection is raised after this try, which would
# otherwise swallow it.
has_doctype = bool(tree.docinfo.doctype) or (
tree.docinfo.internalDTD is not None
)
if not has_doctype:
# Serializing the root element rather than the tree leaves any
# internal DTD subset out of the XML handed to the second parse.
# str() is a no-op at runtime; lxml ships no type stubs, so
# pyright would widen the result to str | bytes.
return str(etree.tostring(root, encoding="unicode"))
except _XMLLimitExceeded as limit:
raise InvalidAggregateReport(f"Invalid XML: {limit}") from limit
except Exception:
return "<a/>"
raise InvalidAggregateReport(_DOCTYPE_ERROR)
def _size_cap_text(content: str | bytes) -> str:
"""Return the reason a report over the size cap is rejected: bytes are
measured as they are, text as UTF-8."""
size = f"{MAX_DECOMPRESSED_REPORT_SIZE // 2**20} MiB"
if isinstance(content, bytes):
return f"the report is larger than {size}"
return f"the report is larger than {size} of UTF-8"
def _exceeds_size_cap(content: str | bytes) -> bool:
"""Return whether ``content`` is more than ``MAX_DECOMPRESSED_REPORT_SIZE``
bytes, a ``str`` measured as UTF-8, as the cap on a compressed report
measures its decompressed bytes. ASCII text and text too long or too
short to matter are decided by length; other text is encoded 1 MiB of
characters at a time, so the measure needs no full copy."""
cap = MAX_DECOMPRESSED_REPORT_SIZE
if isinstance(content, bytes) or len(content) > cap:
return len(content) > cap
# A character is at most 4 bytes of UTF-8.
if len(content) <= cap // 4 or content.isascii():
return False
size = 0
for offset in range(0, len(content), 2**20):
size += len(content[offset : offset + 2**20].encode("utf-8", "surrogatepass"))
if size > cap:
return True
return False
def _report_text(xml: str | bytes) -> str:
"""Return the report as text, after :func:`_check_xml_text`; bytes over
the size cap are rejected before they are decoded (errors ignored)."""
if isinstance(xml, bytes):
if _exceeds_size_cap(xml):
raise InvalidAggregateReport(f"Invalid XML: {_size_cap_text(xml)}")
xml = xml.decode(errors="ignore")
_check_xml_text(xml)
return xml
def _check_xml_text(xml: str) -> None:
"""Reject, before any parser reads it, an aggregate report whose text
alone would make parsing it too costly (GHSA-q7gm-8cj9-94hr): more than
``MAX_DECOMPRESSED_REPORT_SIZE`` bytes of UTF-8 (an uncompressed
attachment or file is held to the cap a compressed one is), more
attributes than ``MAX_XML_NODES``, or a tag longer than
``MAX_XML_TAG_LENGTH``. Raises ``InvalidAggregateReport``."""
if _exceeds_size_cap(xml):
raise InvalidAggregateReport(f"Invalid XML: {_size_cap_text(xml)}")
# The = count is one C pass, so it runs before the slower tag scan.
_check_attribute_count(xml)
_check_tag_lengths(xml)
def _check_tag_lengths(xml: str) -> None:
"""Raise ``InvalidAggregateReport`` when any text between a ``<`` and
the next ``>`` is longer than ``MAX_XML_TAG_LENGTH``, before a parser
builds the tag's attribute list (GHSA-q7gm-8cj9-94hr).
One linear pass without a regex; the text after the last ``>`` is
checked too, so an unterminated tag is caught. A comment or CDATA
section is treated as a tag, which is harmless at this length. XML
allows a raw ``>`` inside a quoted attribute value, which ends a piece
early, so this scan does not bound how many attributes a tag holds;
:func:`_check_attribute_count` does.
"""
# The text is split on ">" one chunk at a time: one split of the whole
# text made a string per tag, +1,318 MiB for 100 MiB of "<a/>". A
# chunk's pieces are measured with max() before any is examined, and
# the unfinished last piece is carried into the next chunk from its
# last "<", so memory stays near one chunk.
carry = ""
for offset in range(0, len(xml), _TAG_SCAN_CHUNK):
pieces = (carry + xml[offset : offset + _TAG_SCAN_CHUNK]).split(">")
carry = pieces.pop()
if max(map(len, pieces), default=0) > MAX_XML_TAG_LENGTH:
for piece in pieces:
_check_tag_piece(piece)
carry = _check_tag_piece(carry)
def _check_attribute_count(xml: str) -> None:
"""Raise ``InvalidAggregateReport`` when ``xml`` holds more ``=``
characters than ``MAX_XML_NODES``: every attribute needs one, and
expat and libxml2 build a tag's attribute list before the node counter
sees it (GHSA-q7gm-8cj9-94hr). ``str.count`` is one linear pass."""
if xml.count("=") > MAX_XML_NODES:
raise InvalidAggregateReport(
f"Invalid XML: more than {MAX_XML_NODES:,} `=` characters, so more "
"attributes than the node limit allows"
)
def _check_tag_piece(piece: str) -> str:
"""Raise the tag-length error when the text of ``piece`` from its last
``<`` is longer than ``MAX_XML_TAG_LENGTH``; return that text, or ``""``
when ``piece`` holds no ``<``."""
start = piece.rfind("<")
if start < 0:
return ""
if len(piece) - start > MAX_XML_TAG_LENGTH:
raise InvalidAggregateReport(
f"Invalid XML: a tag is longer than {MAX_XML_TAG_LENGTH:,} characters"
)
return piece[start:]
_TAG_SCAN_CHUNK = 1 << 16
def _check_report_span(span_seconds: int) -> None:
"""Raise ``InvalidAggregateReport`` when a report's date range is negative
or longer than ``MAX_REPORT_SPAN_DAYS``, before normalization splits it
into one record per day (GHSA-p85v-578x-3wcf). The message holds no
number from the report, whose dates can be thousands of digits long."""
if span_seconds < 0:
raise InvalidAggregateReport("Report date range is negative")
if span_seconds > MAX_REPORT_SPAN_DAYS * 86400:
raise InvalidAggregateReport(
f"Report date range spans more than {MAX_REPORT_SPAN_DAYS} days"
)
def _check_not_future(moment: datetime, what: str) -> None:
"""Raise ``ValueError`` when ``moment`` is more than
``MAX_REPORT_FUTURE_DAYS`` days ahead of the current time, because the
date that names a report's dated index would then create an index for a
date far in the future (#947). ``moment`` must be an aware UTC datetime;
each parser converts the error to its own ``Invalid*Report``."""
if moment > datetime.now(timezone.utc) + timedelta(days=MAX_REPORT_FUTURE_DAYS):
raise ValueError(
f"{what} {moment.isoformat()} is more than "
f"{MAX_REPORT_FUTURE_DAYS} days in the future"
)
def _days_touched(begin: datetime, end: datetime) -> int:
"""Return how many calendar days of ``begin``'s timezone the interval
[begin, end) touches: the number of buckets ``_bucket_interval_by_day()``
makes, before it drops the buckets that receive no messages."""
day_start = datetime(begin.year, begin.month, begin.day, tzinfo=begin.tzinfo)
return max(0, -(-int((end - day_start).total_seconds()) // 86400))
def _normalize_within_limit(
normalize: bool,
xml_records: Any,
begin_dt: datetime,
end_dt: datetime,
report_id: str,
) -> bool:
"""Return whether per-day normalization stays enabled for a report.
Normalization makes one record per record and day, so a report whose
records times days exceed ``MAX_NORMALIZED_RECORDS`` keeps its records
whole, over the report's own range, and one warning is logged
(GHSA-p85v-578x-3wcf).
"""
if not normalize:
return False
record_count = len(xml_records) if isinstance(xml_records, list) else 1
days = _days_touched(begin_dt, end_dt)
if record_count * days <= MAX_NORMALIZED_RECORDS:
return True
logger.warning(
f"Report {sanitize_for_log(str(report_id))} is not normalized to daily "
f"records: {record_count} records over {days} days would make "
f"{record_count * days} records, more than {MAX_NORMALIZED_RECORDS}"
)
return False
def _parse_report_records(
xml_records: Any,
*,
config: ParserConfig,
is_rfc_9990: bool,
begin_dt: datetime,
end_dt: datetime,
normalize: bool,
keep_alive: Callable | None,
dns_budget: _ReverseDNSBudget | None = None,
) -> list[dict[str, Any]]:
"""Parse an aggregate report's ``<record>`` elements (a list, or one
record) into output records, split per day when ``normalize`` is set.
A record of a list that fails to parse is skipped with a warning.
``dns_budget`` is shared by every record (``None`` creates one); see
:class:`_ReverseDNSBudget`."""
if dns_budget is None:
dns_budget = _ReverseDNSBudget()
records: list[dict[str, Any]] = []
if type(xml_records) is list:
for i in range(len(xml_records)):
if keep_alive is not None and i > 0 and i % 20 == 0:
logger.debug("Sending keepalive cmd")
keep_alive()
logger.debug(f"Processed {i}/{len(xml_records)}")
try:
report_record = _parse_report_record(
xml_records[i],
config=config,
is_rfc_9990=is_rfc_9990,
dns_budget=dns_budget,
)
_append_parsed_record(
parsed_record=report_record,
records=records,
begin_dt=begin_dt,
end_dt=end_dt,
normalize=normalize,
)
except Exception as e:
logger.warning(f"Could not parse record: {sanitize_for_log(str(e))}")
else:
report_record = _parse_report_record(
xml_records,
config=config,
is_rfc_9990=is_rfc_9990,
dns_budget=dns_budget,
)
_append_parsed_record(
parsed_record=report_record,
records=records,
begin_dt=begin_dt,
end_dt=end_dt,
normalize=normalize,
)
return records
def parse_aggregate_report_xml(
xml: str | bytes,
*,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
nameservers: list[str] | None = None,
timeout: float = DEFAULT_DNS_TIMEOUT,
retries: int = DEFAULT_DNS_MAX_RETRIES,
keep_alive: Callable | None = None,
normalize_timespan_threshold_hours: float = 24.0,
config: ParserConfig | None = None,
) -> AggregateReport:
"""Parses a DMARC XML report string and returns a consistent dict
Args:
xml (str | bytes): DMARC aggregate report XML (bytes are decoded
with errors ignored)
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
offline (bool): Do not query online for geolocation or DNS
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
timeout (float): Sets the DNS timeout in seconds
retries (int): Number of times to retry DNS queries on timeout or
other transient errors
keep_alive (callable): Keep alive function. Not part of ``config``;
always applies.
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict: The parsed aggregate DMARC report
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=timeout,
dns_retries=retries,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
errors = []
# Parse XML and recover from errors
xml = _report_text(xml)
# Detect the XML namespace before any rewriting strips it. The dmarc-2.0
# namespace is one of the indicators for an RFC 9990 report but it is
# NOT a reliable sole discriminator: the <version> element value is
# ambiguous (RFC 9990's appendix sample uses <version>1.0</version>
# inside the dmarc-2.0 namespace), and real-world reporters frequently
# emit RFC 9990-shaped reports without declaring the namespace at all.
# The final `is_rfc_9990` decision is made post-parse so that
# RFC 9990-only fields (np, testing, discovery_method, generator,
# human_result) can also vote it in.
xml_namespace: str | None = None
namespace_match = xml_namespace_regex.search(xml)
if namespace_match:
xml_namespace = namespace_match.group(1)
first_error: str | None = None
try:
xmltodict.parse(xml, expat=_NoDoctypeExpat, xml_attribs=False)["feedback"]
except _XMLDoctypeFound as e:
raise InvalidAggregateReport(_DOCTYPE_ERROR) from e
except _XMLLimitExceeded as e:
# Rejected outright: the lxml recovery below would be a second pass
# over the same oversized document.
raise InvalidAggregateReport(f"Invalid XML: {e}") from e
except Exception as e:
first_error = f"Invalid XML: {e.__str__()}"
# The recovery runs after the except block, not inside it: while a
# handler runs, the handled exception's traceback keeps the failed
# parse's frames, and with them its partial tree, alive.
if first_error is not None:
errors.append(first_error)
xml = _recover_xml(xml)
try:
# Replace XML header (sometimes they are invalid)
xml = xml_header_regex.sub('<?xml version="1.0"?>', xml)
# Remove invalid schema tags
xml = xml_schema_regex.sub("", xml)
report = _parse_limited_xml(xml)["feedback"]
_scalarize_report(report)
report_metadata = report["report_metadata"]
# The report_metadata strings are strings or None here:
# xmltodict kept no xml:lang attribute (xml_attribs=False) and
# _scalarize_report() any child element. Text in place of the
# section's elements is a missing section.
if not isinstance(report_metadata, dict):
raise AttributeError("report_metadata holds no elements")
schema = "draft"
if "version" in report:
schema = report["version"]
new_report: dict[str, Any] = {
"xml_schema": schema,
"xml_namespace": xml_namespace,
}
new_report_metadata: dict[str, Any] = {}
if report_metadata["org_name"] is None:
if report_metadata["email"] is not None:
report_metadata["org_name"] = report_metadata["email"].split("@")[-1]
org_name = report_metadata["org_name"]
if org_name is not None and " " not in org_name:
new_org_name = get_base_domain(org_name)
if new_org_name is not None:
org_name = new_org_name
if not org_name:
logger.debug(f"Could not parse org_name from XML.\r\n{report.__str__()}")
raise KeyError(
"Organization name is missing. This field is a requirement "
"for saving the report"
)
new_report_metadata["org_name"] = org_name
new_report_metadata["org_email"] = report_metadata["email"]
# extra_contact_info is langAttrString in RFC 9990 (xs:string in
# RFC 7489); its lang attribute is dropped when the report is parsed.
extra = _text(report_metadata.get("extra_contact_info"))
new_report_metadata["org_extra_contact_info"] = extra
new_report_metadata["report_id"] = report_metadata["report_id"]
report_id = new_report_metadata["report_id"]
report_id = report_id.replace("<", "").replace(">", "").split("@")[0]
new_report_metadata["report_id"] = report_id
date_range = report["report_metadata"]["date_range"]
begin_ts = int(date_range["begin"].split(".")[0])
end_ts = int(date_range["end"].split(".")[0])
span_seconds = end_ts - begin_ts
_check_report_span(span_seconds)
# The end is checked as well as the begin: a normalized report's
# per-day records each name an index from their own interval start,
# and every interval start lies at or before the report's end.
try:
for stamp, what in (
(begin_ts, "Report begin date"),
(end_ts, "Report end date"),
):
_check_not_future(datetime.fromtimestamp(stamp, timezone.utc), what)
except ValueError as error:
raise InvalidAggregateReport(str(error)) from error
normalize_timespan = (
span_seconds > cfg.normalize_timespan_threshold_hours * 3600
)
date_range["begin"] = timestamp_to_human(begin_ts)
date_range["end"] = timestamp_to_human(end_ts)
new_report_metadata["begin_date"] = date_range["begin"]
new_report_metadata["end_date"] = date_range["end"]
new_report_metadata["timespan_requires_normalization"] = normalize_timespan
new_report_metadata["original_timespan_seconds"] = span_seconds
begin_dt = human_timestamp_to_datetime(
new_report_metadata["begin_date"], to_utc=True
)
end_dt = human_timestamp_to_datetime(
new_report_metadata["end_date"], to_utc=True
)
# <error> is langAttrString in RFC 9990 (xs:string in RFC 7489) and
# was cardinality-narrowed from "unbounded" to "1" in RFC 9990, but
# the parser still accepts a list for backward compatibility with
# RFC 7489 reports that carry multiple errors.
if "error" in report["report_metadata"]:
raw_errors = report["report_metadata"]["error"]
if not isinstance(raw_errors, list):
raw_errors = [raw_errors]
errors = [text for text in (_text(e) for e in raw_errors) if text]
new_report_metadata["errors"] = errors
# <generator> is a plain xs:string in RFC 9990; attributes and child
# elements are already removed, and _text() keeps it a string.
generator = _text(report_metadata.get("generator"))
new_report_metadata["generator"] = generator
new_report["report_metadata"] = new_report_metadata
policy_published = report["policy_published"]
if type(policy_published) is list:
policy_published = policy_published[0]
# Final RFC 9990 detection: the dmarc-2.0 XML namespace OR any
# RFC 9990-only field. Real-world reporters that follow the schema
# without declaring the namespace still get RFC 9990-aware
# warnings (missing DKIM selector, removed override-reason types,
# etc.) and a truthful audit trail in `xml_namespace`.
rfc_9990_only_policy_fields = {"np", "testing", "discovery_method"}
is_rfc_9990 = (
xml_namespace == RFC_9990_NAMESPACE
or "generator" in report_metadata
or any(f in policy_published for f in rfc_9990_only_policy_fields)
)
new_policy_published: dict[str, Any] = {}
new_policy_published["domain"] = policy_published["domain"]
adkim = "r"
if "adkim" in policy_published:
if policy_published["adkim"] is not None:
adkim = policy_published["adkim"]
new_policy_published["adkim"] = adkim
aspf = "r"
if "aspf" in policy_published:
if policy_published["aspf"] is not None:
aspf = policy_published["aspf"]
new_policy_published["aspf"] = aspf
new_policy_published["p"] = policy_published["p"]
sp = new_policy_published["p"]
if "sp" in policy_published:
if policy_published["sp"] is not None:
sp = policy_published["sp"]
new_policy_published["sp"] = sp
pct = None
if "pct" in policy_published:
if policy_published["pct"] is not None:
pct = policy_published["pct"]
new_policy_published["pct"] = pct
fo = None
if "fo" in policy_published:
if policy_published["fo"] is not None:
fo = policy_published["fo"]
new_policy_published["fo"] = fo
np_ = None
if "np" in policy_published:
if policy_published["np"] is not None:
np_ = policy_published["np"]
if np_ not in ("none", "quarantine", "reject"):
logger.warning(f"Invalid np value: {sanitize_for_log(str(np_))}")
new_policy_published["np"] = np_
testing = None
if "testing" in policy_published:
if policy_published["testing"] is not None:
testing = policy_published["testing"]
if testing not in ("n", "y"):
logger.warning(
f"Invalid testing value: {sanitize_for_log(str(testing))}"
)
new_policy_published["testing"] = testing
discovery_method = None
if "discovery_method" in policy_published:
if policy_published["discovery_method"] is not None:
discovery_method = policy_published["discovery_method"]
if discovery_method not in ("psl", "treewalk"):
logger.warning(
f"Invalid discovery_method value: "
f"{sanitize_for_log(str(discovery_method))}"
)
new_policy_published["discovery_method"] = discovery_method
new_report["policy_published"] = new_policy_published
normalize_timespan = _normalize_within_limit(
normalize_timespan, report["record"], begin_dt, end_dt, report_id
)
new_report_metadata["timespan_requires_normalization"] = normalize_timespan
records = _parse_report_records(
report["record"],
config=cfg,
is_rfc_9990=is_rfc_9990,
begin_dt=begin_dt,
end_dt=end_dt,
normalize=normalize_timespan,
keep_alive=keep_alive,
dns_budget=_ReverseDNSBudget(),
)
new_report["records"] = records
return cast(AggregateReport, new_report)
except InvalidAggregateReport:
# Already specific (an XML or date range limit); do not re-wrap it
# as an "Unexpected error" below.
raise
except expat.ExpatError as error:
raise InvalidAggregateReport(f"Invalid XML: {error.__str__()}") from error
except KeyError as error:
raise InvalidAggregateReport(f"Missing field: {error.__str__()}") from error
except AttributeError as error:
raise InvalidAggregateReport("Report missing required section") from error
except Exception as error:
raise InvalidAggregateReport(
f"Unexpected error: {error.__str__()}{_exc_origin(error)}"
) from error
def _decompress_gzip_bounded(data: bytes) -> bytes:
"""
Decompresses a gzip stream, refusing to produce more than
``MAX_DECOMPRESSED_REPORT_SIZE`` bytes.
The limit is read from the module global on every call so that it stays
a single source of truth (and can be patched in tests).
Args:
data: The gzip stream
Returns:
bytes: The decompressed data
Raises:
ParserError: The decompressed data exceeds the limit, or the gzip
stream ends before the decompressor reaches its end marker.
zlib.error: The stream is corrupt (bad header, deflate data, or
CRC); ``extract_report()`` wraps it in ``ParserError``.
"""
limit = MAX_DECOMPRESSED_REPORT_SIZE
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
decompressed = decompressor.decompress(data, max_length=limit + 1)
if len(decompressed) > limit:
raise ParserError(f"Decompressed report exceeds the {limit} byte limit")
# ``flush()`` ignores ``max_length``, so it must not run until the
# bounded read above has been accepted. It is bounded here: a bounded
# ``decompress()`` that returns fewer than ``max_length`` bytes has
# either consumed all of its input or hit the end of the stream (after
# which any trailing bytes are ``unused_data``, not input), so nothing
# unbounded is left for ``flush()`` to produce.
decompressed += decompressor.flush()
if not decompressor.eof:
# A one-shot zlib.decompress() raises on a stream that ends early;
# a decompressobj just stops, so the truncation is detected here.
raise ParserError("Incomplete or truncated gzip stream")
return decompressed
def extract_report(content: bytes | str | BinaryIO) -> str:
"""
Extracts report text from zip- or gzip-compressed content, and returns
plain XML or JSON content decoded as-is.
A caller-supplied file-like object is read from but never closed by
this function, on either the success or the exception path; it is
left open and positioned wherever the function's own reads left it,
so the caller is free to seek(0) and reuse it. A seekable stream is
seeked to position 0 unconditionally (not back to wherever the caller
had left it) after its 6-byte header is sniffed, and then read from
there, so a successful call typically leaves it at EOF. A non-seekable
stream is drained into a buffer this function creates and closes
itself; the caller's stream is left open but exhausted.
Args:
content: The report as a base64-encoded string, file-like object,
or bytes. A string that is not valid base64 is returned
unchanged.
Returns:
str: The extracted text
"""
file_object: BinaryIO | None = None
# True while file_object is a BytesIO this function created itself
# (from a str/bytes input, or as a buffer copied from a non-seekable
# caller stream); it is closed in the ``finally`` below. It is set to
# False when file_object instead aliases a caller-supplied seekable
# stream, which this function must never close.
owns_file_object = True
header: bytes
try:
if isinstance(content, str):
try:
file_object = BytesIO(
b64decode(
content.replace("\n", "").replace("\r", ""), validate=True
)
)
except binascii.Error:
return content
header = file_object.read(6)
file_object.seek(0)
elif isinstance(content, (bytes)):
file_object = BytesIO(bytes(content))
header = file_object.read(6)
file_object.seek(0)
else:
stream = cast(BinaryIO, content)
seekable = getattr(stream, "seekable", None)
can_seek = False
if callable(seekable):
try:
can_seek = bool(seekable())
except Exception:
can_seek = False
if can_seek:
header_raw = stream.read(6)
if isinstance(header_raw, str):
raise ParserError("File objects must be opened in binary (rb) mode")
header = bytes(header_raw)
stream.seek(0)
# file_object aliases the caller's own stream here, so it
# must not be closed by this function.
file_object = stream
owns_file_object = False
else:
header_raw = stream.read(6)
if isinstance(header_raw, str):
raise ParserError("File objects must be opened in binary (rb) mode")
header = bytes(header_raw)
remainder = stream.read()
file_object = BytesIO(header + bytes(remainder))
if header[: len(MAGIC_ZIP)] == MAGIC_ZIP:
_zip = zipfile.ZipFile(file_object)
limit = MAX_DECOMPRESSED_REPORT_SIZE
member = _zip.open(_zip.namelist()[0]).read(limit + 1)
if len(member) > limit:
raise ParserError(f"Decompressed report exceeds the {limit} byte limit")
report = member.decode(errors="ignore")
elif header[: len(MAGIC_GZIP)] == MAGIC_GZIP:
report = _decompress_gzip_bounded(file_object.read()).decode(
errors="ignore"
)
elif (
header[: len(MAGIC_XML)] == MAGIC_XML
or header[: len(MAGIC_XML_TAG)] == MAGIC_XML_TAG
or header[: len(MAGIC_JSON)] == MAGIC_JSON
):
# An uncompressed report is held to the decompressed cap, read
# at most one byte past it rather than decoded whole.
limit = MAX_DECOMPRESSED_REPORT_SIZE
data = file_object.read(limit + 1)
if len(data) > limit:
raise ParserError(f"Report exceeds the {limit} byte limit")
report = data.decode(errors="ignore")
else:
raise ParserError("Not a valid zip, gzip, json, or xml file")
except Exception as error:
raise ParserError(
f"Invalid archive file: {error.__str__()}{_exc_origin(error)}"
) from error
finally:
if file_object and owns_file_object:
try:
file_object.close()
except Exception:
# Best-effort close; a failure here shouldn't shadow
# whatever exception (or successful result) is already
# propagating out of this function.
pass
return report
def extract_report_from_file_path(
file_path: str | bytes | os.PathLike[str] | os.PathLike[bytes],
) -> str:
"""Extracts report from a file at the given file_path"""
try:
with open(os.fspath(file_path), "rb") as report_file:
return extract_report(report_file.read())
except FileNotFoundError:
raise ParserError("File was not found")
def parse_aggregate_report_file(
_input: str | bytes | BinaryIO,
*,
offline: bool = False,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
ip_db_path: str | None = None,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
keep_alive: Callable | None = None,
normalize_timespan_threshold_hours: float = 24.0,
config: ParserConfig | None = None,
) -> AggregateReport:
"""Parses a file at the given path, a file-like object, or bytes as an
aggregate DMARC report
``_input`` is forwarded to ``extract_report()`` unchanged, so a
caller-supplied file-like object is read from but never closed, on
either the success or the exception path; see ``extract_report()``
for exactly how such an object is left positioned afterward.
Args:
_input (str | bytes | IO): A path to a file, a file-like object, or bytes
offline (bool): Do not query online for geolocation or DNS
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
dns_timeout (float): Sets the DNS timeout in seconds
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
keep_alive (callable): Keep alive function. Not part of ``config``;
always applies.
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict: The parsed DMARC aggregate report
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
try:
xml = extract_report(_input)
except Exception as e:
raise InvalidAggregateReport(str(e) + _exc_origin(e)) from e
return parse_aggregate_report_xml(
xml,
config=cfg,
keep_alive=keep_alive,
)
def parsed_aggregate_reports_to_csv_rows(
reports: AggregateReport | list[AggregateReport],
) -> list[dict[str, Any]]:
"""
Converts one or more parsed aggregate reports to list of dicts in flat CSV
format
Args:
reports: A parsed aggregate report or list of parsed aggregate reports
Returns:
list: Parsed aggregate report data as a list of dicts in flat CSV
format
"""
def to_str(obj):
return str(obj).lower()
if isinstance(reports, dict):
reports = [reports]
rows = []
for report in reports:
xml_schema = report["xml_schema"]
org_name = report["report_metadata"]["org_name"]
org_email = report["report_metadata"]["org_email"]
org_extra_contact = report["report_metadata"]["org_extra_contact_info"]
report_id = report["report_metadata"]["report_id"]
begin_date = report["report_metadata"]["begin_date"]
end_date = report["report_metadata"]["end_date"]
normalized_timespan = report["report_metadata"][
"timespan_requires_normalization"
]
errors = "|".join(report["report_metadata"]["errors"])
domain = report["policy_published"]["domain"]
adkim = report["policy_published"]["adkim"]
aspf = report["policy_published"]["aspf"]
p = report["policy_published"]["p"]
sp = report["policy_published"]["sp"]
pct = report["policy_published"]["pct"]
fo = report["policy_published"]["fo"]
np_ = report["policy_published"].get("np", None)
testing = report["policy_published"].get("testing", None)
discovery_method = report["policy_published"].get("discovery_method", None)
report_dict: dict[str, Any] = dict(
xml_schema=xml_schema,
org_name=org_name,
org_email=org_email,
org_extra_contact_info=org_extra_contact,
report_id=report_id,
begin_date=begin_date,
end_date=end_date,
normalized_timespan=normalized_timespan,
errors=errors,
domain=domain,
adkim=adkim,
aspf=aspf,
p=p,
sp=sp,
np=np_,
pct=pct,
fo=fo,
testing=testing,
discovery_method=discovery_method,
)
for record in report["records"]:
row: dict[str, Any] = report_dict.copy()
row["begin_date"] = record["interval_begin"]
row["end_date"] = record["interval_end"]
row["source_ip_address"] = record["source"]["ip_address"]
row["source_country"] = record["source"]["country"]
row["source_reverse_dns"] = record["source"]["reverse_dns"]
row["source_base_domain"] = record["source"]["base_domain"]
row["source_name"] = record["source"]["name"]
row["source_type"] = record["source"]["type"]
row["source_asn"] = record["source"]["asn"]
row["source_as_name"] = record["source"]["as_name"]
row["source_as_domain"] = record["source"]["as_domain"]
row["count"] = record["count"]
row["spf_aligned"] = record["alignment"]["spf"]
row["dkim_aligned"] = record["alignment"]["dkim"]
row["dmarc_aligned"] = record["alignment"]["dmarc"]
row["disposition"] = record["policy_evaluated"]["disposition"]
policy_override_reasons = list(
map(
lambda r_: r_.get("type") or "none",
record["policy_evaluated"]["policy_override_reasons"],
)
)
policy_override_comments = list(
map(
lambda r_: r_.get("comment") or "none",
record["policy_evaluated"]["policy_override_reasons"],
)
)
row["policy_override_reasons"] = ",".join(policy_override_reasons)
row["policy_override_comments"] = "|".join(policy_override_comments)
row["envelope_from"] = record["identifiers"]["envelope_from"]
row["header_from"] = record["identifiers"]["header_from"]
envelope_to = record["identifiers"]["envelope_to"]
row["envelope_to"] = envelope_to
dkim_domains = []
dkim_selectors = []
dkim_results = []
for dkim_result in record["auth_results"]["dkim"]:
dkim_domains.append(dkim_result["domain"])
if "selector" in dkim_result:
dkim_selectors.append(dkim_result["selector"])
dkim_results.append(dkim_result["result"])
row["dkim_domains"] = ",".join(map(to_str, dkim_domains))
row["dkim_selectors"] = ",".join(map(to_str, dkim_selectors))
row["dkim_results"] = ",".join(map(to_str, dkim_results))
spf_domains = []
spf_scopes = []
spf_results = []
for spf_result in record["auth_results"]["spf"]:
spf_domains.append(spf_result["domain"])
spf_scopes.append(spf_result["scope"])
spf_results.append(spf_result["result"])
row["spf_domains"] = ",".join(map(to_str, spf_domains))
row["spf_scopes"] = ",".join(map(to_str, spf_scopes))
row["spf_results"] = ",".join(map(to_str, spf_results))
rows.append(row)
for r in rows:
for k, v in r.items():
if type(v) not in [str, int, bool]:
r[k] = ""
return rows
def parsed_aggregate_reports_to_csv(
reports: AggregateReport | list[AggregateReport],
) -> str:
"""
Converts one or more parsed aggregate reports to flat CSV format, including
headers
Args:
reports: A parsed aggregate report or list of parsed aggregate reports
Returns:
str: Parsed aggregate report data in flat CSV format, including headers
"""
fields = [
"xml_schema",
"org_name",
"org_email",
"org_extra_contact_info",
"report_id",
"begin_date",
"end_date",
"normalized_timespan",
"errors",
"domain",
"adkim",
"aspf",
"p",
"sp",
"np",
"pct",
"fo",
"testing",
"discovery_method",
"source_ip_address",
"source_country",
"source_reverse_dns",
"source_base_domain",
"source_name",
"source_type",
"source_asn",
"source_as_name",
"source_as_domain",
"count",
"spf_aligned",
"dkim_aligned",
"dmarc_aligned",
"disposition",
"policy_override_reasons",
"policy_override_comments",
"envelope_from",
"header_from",
"envelope_to",
"dkim_domains",
"dkim_selectors",
"dkim_results",
"spf_domains",
"spf_scopes",
"spf_results",
]
csv_file_object = StringIO(newline="\n")
writer = DictWriter(csv_file_object, fields)
writer.writeheader()
rows = parsed_aggregate_reports_to_csv_rows(reports)
for row in rows:
writer.writerow({key: _csv_safe(value) for key, value in row.items()})
csv_file_object.flush()
return csv_file_object.getvalue()
def parse_failure_report(
feedback_report: str,
sample: str,
msg_date: datetime,
*,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
ip_db_path: str | None = None,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
config: ParserConfig | None = None,
) -> FailureReport:
"""
Converts a DMARC failure report and sample to a dict
Args:
feedback_report (str): A message's feedback report as a string
sample (str): The RFC 822 headers or RFC 822 message sample
msg_date (datetime): The message's date, used as the arrival date
when the feedback report has no ``Arrival-Date`` field
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
offline (bool): Do not query online for geolocation or DNS
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
dns_timeout (float): Sets the DNS timeout in seconds
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict: A parsed report and sample
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
)
delivery_results = ["delivered", "spam", "policy", "reject", "other"]
try:
parsed_report: dict[str, Any] = {}
report_values = feedback_report_regex.findall(feedback_report)
for report_value in report_values:
key = report_value[0].lower().replace("-", "_")
parsed_report[key] = report_value[1]
if "arrival_date" not in parsed_report:
if msg_date is None:
raise InvalidFailureReport("Failure sample is not a valid email")
parsed_report["arrival_date"] = msg_date.isoformat()
if "version" not in parsed_report:
parsed_report["version"] = None
if "user_agent" not in parsed_report:
parsed_report["user_agent"] = None
if "delivery_result" not in parsed_report:
parsed_report["delivery_result"] = None
else:
for delivery_result in delivery_results:
if delivery_result in parsed_report["delivery_result"].lower():
parsed_report["delivery_result"] = delivery_result
break
if parsed_report["delivery_result"] not in delivery_results:
parsed_report["delivery_result"] = "other"
try:
arrival_utc = human_timestamp_to_datetime(
parsed_report["arrival_date"], to_utc=True
)
except (ValueError, OverflowError) as error:
raise InvalidFailureReport(
f"Invalid Arrival-Date: {sanitize_for_log(str(error))}"
) from error
try:
_check_not_future(arrival_utc, "Arrival-Date")
except ValueError as error:
raise InvalidFailureReport(str(error)) from error
arrival_utc = arrival_utc.strftime("%Y-%m-%d %H:%M:%S")
parsed_report["arrival_date_utc"] = arrival_utc
ip_address = _strip_zone_id(
re.split(r"\s", parsed_report["source_ip"]).pop(0), "Source-IP"
)
parsed_report_source = get_ip_address_info(
ip_address,
cache=cfg.ip_address_cache,
ip_db_path=cfg.ip_db_path,
always_use_local_files=cfg.always_use_local_files,
reverse_dns_map_path=cfg.reverse_dns_map_path,
reverse_dns_map_url=cfg.reverse_dns_map_url,
reverse_dns_map=cfg.reverse_dns_map,
offline=cfg.offline,
nameservers=cfg.nameservers,
timeout=cfg.dns_timeout,
retries=cfg.dns_retries,
psl_overrides_path=cfg.psl_overrides_path,
psl_overrides_url=cfg.psl_overrides_url,
)
parsed_report["source"] = parsed_report_source
del parsed_report["source_ip"]
# Identity-Alignment is REQUIRED per RFC 9991 §4. Default silently for
# backward compatibility with pre-9991 reporters, but log so the
# offending reporter is visible. Values are CFWS-separated per the
# ABNF, so each mechanism is stripped after splitting.
if "identity_alignment" not in parsed_report:
logger.warning(
"Failure report missing required 'Identity-Alignment' "
"field (RFC 9991 §4); defaulting to no aligned mechanisms"
)
parsed_report["authentication_mechanisms"] = []
else:
raw_alignment = parsed_report["identity_alignment"].strip()
if raw_alignment.lower() == "none":
parsed_report["authentication_mechanisms"] = []
else:
parsed_report["authentication_mechanisms"] = [
m.strip() for m in raw_alignment.split(",") if m.strip()
]
del parsed_report["identity_alignment"]
# Auth-Failure is REQUIRED per RFC 9991 §4. Comma-separated per ABNF
# so strip each token.
if "auth_failure" not in parsed_report:
logger.warning(
"Failure report missing required 'Auth-Failure' field "
"(RFC 9991 §4); defaulting to 'dmarc'"
)
parsed_report["auth_failure"] = "dmarc"
parsed_report["auth_failure"] = [
f.strip() for f in parsed_report["auth_failure"].split(",") if f.strip()
]
# Feedback-Type is REQUIRED per RFC 5965 §3.1, but some gateways
# (e.g. Exim/cPanel-based ones that send a plain-text summary without
# a machine-readable message/feedback-report part) omit it. The
# Elasticsearch/OpenSearch outputs require the key, so default it
# instead of dropping the report there (see issue #332).
if "feedback_type" not in parsed_report:
logger.warning(
"Failure report missing required 'Feedback-Type' field "
"(RFC 5965 §3.1); defaulting to 'auth-failure'"
)
parsed_report["feedback_type"] = "auth-failure"
# Authentication-Results is likewise REQUIRED per RFC 6591 §3.1 and
# required by the Elasticsearch/OpenSearch outputs.
if "authentication_results" not in parsed_report:
logger.warning(
"Failure report missing required 'Authentication-Results' "
"field (RFC 6591 §3.1); defaulting to None"
)
parsed_report["authentication_results"] = None
optional_fields = [
"original_envelope_id",
"dkim_domain",
"original_mail_from",
"original_rcpt_to",
]
for optional_field in optional_fields:
if optional_field not in parsed_report:
parsed_report[optional_field] = None
parsed_sample = parse_email(
sample, strip_attachment_payloads=cfg.strip_attachment_payloads
)
if "reported_domain" not in parsed_report:
# reported_domain is a required str in the FailureReport contract
# (parsedmarc/types.py) and is read directly by the Elasticsearch
# and OpenSearch outputs, so it cannot be defaulted to None. When
# the report omits the Reported-Domain field and the sample's From
# header is missing or unparseable, the report is unusable.
sample_from = parsed_sample.get("from")
if not isinstance(sample_from, dict) or not sample_from.get("domain"):
raise InvalidFailureReport(
"The failure report has no Reported-Domain field, and no "
"domain could be parsed from the From header of the "
"included sample message"
)
parsed_report["reported_domain"] = sample_from["domain"]
sample_headers_only = False
number_of_attachments = len(parsed_sample["attachments"])
if number_of_attachments < 1 and parsed_sample["body"] is None:
sample_headers_only = True
if sample_headers_only and parsed_sample["has_defects"]:
del parsed_sample["defects"]
del parsed_sample["defects_categories"]
del parsed_sample["has_defects"]
parsed_report["sample_headers_only"] = sample_headers_only
parsed_report["sample"] = sample
parsed_report["parsed_sample"] = parsed_sample
return cast(FailureReport, parsed_report)
except InvalidFailureReport:
# Already a clear, specific message; do not re-wrap it as an
# "Unexpected error" below.
raise
except KeyError as error:
raise InvalidFailureReport(f"Missing value: {error.__str__()}") from error
except MessageLimitExceeded as error:
raise InvalidFailureReport(f"Sample message rejected: {error}") from error
except Exception as error:
raise InvalidFailureReport(
f"Unexpected error: {error.__str__()}{_exc_origin(error)}"
) from error
def parsed_failure_reports_to_csv_rows(
reports: FailureReport | list[FailureReport],
) -> list[dict[str, Any]]:
"""
Converts one or more parsed failure reports to a list of dicts in flat CSV
format
Args:
reports: A parsed failure report or list of parsed failure reports
Returns:
list: Parsed failure report data as a list of dicts in flat CSV format
"""
if isinstance(reports, dict):
reports = [reports]
rows = []
for report in reports:
row: dict[str, Any] = dict(report)
row["source_ip_address"] = report["source"]["ip_address"]
row["source_reverse_dns"] = report["source"]["reverse_dns"]
row["source_base_domain"] = report["source"]["base_domain"]
row["source_name"] = report["source"]["name"]
row["source_type"] = report["source"]["type"]
row["source_asn"] = report["source"]["asn"]
row["source_as_name"] = report["source"]["as_name"]
row["source_as_domain"] = report["source"]["as_domain"]
row["source_country"] = report["source"]["country"]
del row["source"]
row["subject"] = report["parsed_sample"].get("subject")
row["auth_failure"] = ",".join(report["auth_failure"])
authentication_mechanisms = report["authentication_mechanisms"]
row["authentication_mechanisms"] = ",".join(authentication_mechanisms)
del row["sample"]
del row["parsed_sample"]
rows.append(row)
return rows
def parsed_failure_reports_to_csv(
reports: FailureReport | list[FailureReport],
) -> str:
"""
Converts one or more parsed failure reports to flat CSV format, including
headers
Args:
reports: A parsed failure report or list of parsed failure reports
Returns:
str: Parsed failure report data in flat CSV format, including headers
"""
fields = [
"feedback_type",
"user_agent",
"version",
"original_envelope_id",
"original_mail_from",
"original_rcpt_to",
"arrival_date",
"arrival_date_utc",
"subject",
"message_id",
"authentication_results",
"dkim_domain",
"source_ip_address",
"source_country",
"source_reverse_dns",
"source_base_domain",
"source_name",
"source_type",
"source_asn",
"source_as_name",
"source_as_domain",
"delivery_result",
"auth_failure",
"reported_domain",
"authentication_mechanisms",
"sample_headers_only",
]
csv_file = StringIO()
csv_writer = DictWriter(csv_file, fieldnames=fields)
csv_writer.writeheader()
rows = parsed_failure_reports_to_csv_rows(reports)
for row in rows:
new_row: dict[str, Any] = {}
for key in fields:
new_row[key] = _csv_safe(row.get(key))
csv_writer.writerow(new_row)
return csv_file.getvalue()
def _decode_mime_payload(part: email.message.Message, fallback: str) -> str:
"""
Returns a MIME part's payload decoded per its ``Content-Transfer-Encoding``
Three non-obvious constraints shape this helper:
- Only ``quoted-printable`` and ``base64`` parts are touched at all. The
caller parses the message from a ``str``, so for a part with a 7bit,
8bit, or absent ``Content-Transfer-Encoding`` there is nothing to undo,
and ``compat32``'s ``get_payload(decode=True)`` would round-trip the
already-correct text through ``raw-unicode-escape`` bytes — re-decoding
those as the declared charset mangles every non-ASCII character. Such
parts return ``fallback``, which is the payload text as-is.
- The standard library's email parser nests **every** ``message/*``
subtype — including ``message/feedback-report`` and ``message/rfc822`` —
as a child ``Message`` object, so ``get_payload(decode=True)`` returns
``None`` for those parts rather than decoded bytes. The caller's
re-serialization of that child message is passed in as ``fallback``, and
any transfer encoding is undone here instead.
- A composite part carrying a real ``Content-Transfer-Encoding`` of
``quoted-printable`` or ``base64`` is illegal per RFC 2045 §6.4, but
real reporters send them anyway (issue
`#882 <https://github.com/domainaware/parsedmarc/issues/882>`_), and the
standard library nests the still-encoded text as-is. Parsing that text
as a message can insert a header/body separator that the encoded text
never had, which has to be undone before decoding; see below.
Any unexpected failure returns ``fallback`` unchanged, so a report that
parsed before cannot start failing because of this decoding step.
Args:
part: The MIME part
fallback: The payload text to use when the part carries no transfer
encoding to undo, or cannot be decoded with ``decode=True``
(i.e. nested ``message/*`` parts)
Returns:
str: The decoded payload
"""
try:
cte = (part.get("Content-Transfer-Encoding") or "").strip().lower()
if cte not in ("quoted-printable", "base64"):
return fallback
payload_bytes = part.get_payload(decode=True)
if isinstance(payload_bytes, bytes):
charset = part.get_content_charset() or "utf-8"
try:
return payload_bytes.decode(charset, errors="replace")
except LookupError:
return payload_bytes.decode("utf-8", errors="replace")
# Nested message/* part: undo the transfer encoding by hand.
#
# The still-encoded text was parsed as a message first. A soft line
# break (RFC 2045 §6.7) splits a long field across lines, and the
# continuation line has neither a colon nor leading whitespace, so the
# parser records a MissingHeaderBodySeparatorDefect and treats the
# remainder as the body -- which makes the re-serialized `fallback`
# carry a blank line that the encoded text never had. Decoding then
# consumes the soft break and leaves that inserted newline in the
# middle of the value, truncating it. Drop exactly that one separator:
# a header value cannot contain a blank line, so the first "\n\n" is
# the inserted boundary. Text with a genuine separator raises no
# defect, and nothing is removed.
text = fallback
child = part.get_payload()
if isinstance(child, list) and child:
child = child[0]
if isinstance(child, email.message.Message) and any(
isinstance(defect, email.errors.MissingHeaderBodySeparatorDefect)
for defect in child.defects
):
text = fallback.replace("\n\n", "\n", 1)
if cte == "quoted-printable":
return quopri.decodestring(text.encode("utf-8", errors="replace")).decode(
"utf-8", errors="replace"
)
return b64decode(text).decode("utf-8", errors="replace")
except Exception:
return fallback
def parse_report_email(
input_: bytes | str,
*,
offline: bool = False,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
keep_alive: Callable | None = None,
normalize_timespan_threshold_hours: float = 24.0,
config: ParserConfig | None = None,
) -> ParsedReport:
"""
Parses a DMARC or SMTP TLS report from an email
Args:
input_: An emailed DMARC report in RFC 822 format, as bytes or a string
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map
reverse_dns_map_url (str): URL to a reverse DNS map
offline (bool): Do not query online for geolocation or DNS
nameservers (list): A list of one or more nameservers to use
dns_timeout (float): Sets the DNS timeout in seconds
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
keep_alive (callable): keep alive function. Not part of ``config``;
always applies.
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict:
* ``report_type``: ``aggregate``, ``failure``, or ``smtp_tls``
* ``report``: The parsed report
Raises:
ParserError: If the message is not a valid report. Whatever the
input, only ``ParserError`` subclasses are raised.
A message that is not a valid report, or whose attachment is
not one, raises ``InvalidDMARCReport``, or
``InvalidSMTPTLSReport`` for an SMTP TLS attachment (both are
``ParserError`` subclasses). Any other failure to parse the
message raises a plain ``ParserError``.
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
result: ParsedReport | None = None
msg_date: datetime = datetime.now(timezone.utc)
try:
input_data: str | bytes | bytearray | memoryview = input_
if isinstance(input_data, (bytes, bytearray, memoryview)):
input_bytes = bytes(input_data)
if is_outlook_msg(input_bytes):
converted = convert_outlook_msg(input_bytes)
if isinstance(converted, str):
input_str = converted
else:
input_str = bytes(converted).decode(
encoding="utf8", errors="replace"
)
else:
input_str = input_bytes.decode(encoding="utf8", errors="replace")
else:
input_str = input_data
# Reject a message whose structure makes parsing slow before any
# parser builds its tree (GHSA-qcc2-4x3v-v4g7)
check_message_limits(input_str)
msg = mailparser.parse_from_string(input_str)
msg_headers = json.loads(msg.headers_json)
if "Date" in msg_headers:
try:
msg_date = human_timestamp_to_datetime(msg_headers["Date"])
except (ValueError, OverflowError) as e:
raise InvalidDMARCReport(
f"Invalid Date header: {sanitize_for_log(str(e))}"
) from e
date = email.utils.format_datetime(msg_date)
msg = email.message_from_string(input_str)
except MessageLimitExceeded as e:
raise InvalidDMARCReport(f"Message rejected: {e}") from e
except InvalidDMARCReport:
raise
except Exception as e:
raise ParserError(e.__str__() + _exc_origin(e)) from e
subject_text = "None"
feedback_report = None
smtp_tls_report = None
sample = None
# id() of every sample candidate and every part inside one
sample_parts: set[int] = set()
is_feedback_report: bool = False
if "From" in msg_headers:
logger.info(
"Parsing mail from {} on {}".format(
sanitize_for_log(str(msg_headers["From"])), date
)
)
if "Subject" in msg_headers:
# RFC 2047 decoding can yield raw CR/LF, so escape control
# characters before the subject reaches an error message or log.
subject_text = sanitize_for_log(str(msg_headers["Subject"]))
# Serializing a part (``str(payload_obj[0])``) re-renders any
# ``message/rfc822`` sub-message, which can raise e.g.
# ``email.errors.HeaderWriteError`` on a control character in a header or
# ``RecursionError`` on deeply nested multiparts. Callers rely on this
# function raising only ``ParserError`` subclasses so that one bad message
# is skipped (and moved to ``Invalid``) rather than aborting a whole
# mailbox batch (GHSA-qxwv-2vwj-v828).
try:
for part in msg.walk():
if id(part) in sample_parts:
# The sample is the reported message, spoofer's content
# included; an attachment inside it is not a report.
continue
content_type = part.get_content_type().lower()
payload_obj = part.get_payload()
if not isinstance(payload_obj, list):
payload_obj = [payload_obj]
if content_type.startswith("multipart/") or content_type == "text/html":
continue
# A part is serialized only where its text is used: str() of a
# composite part renders its whole subtree, and walk() visits
# every level, so serializing each one was quadratic in the
# nesting (98 multiparts around a 20 MiB leaf took 26.6 s;
# GHSA-qcc2-4x3v-v4g7).
if is_feedback_report and content_type in EMAIL_SAMPLE_CONTENT_TYPES:
# A feedback report has one sample (RFC 6591 §3.1); a message
# nested inside it belongs to it and must not replace it.
if sample is None:
sample = _decode_mime_payload(part, str(payload_obj[0]))
# A later candidate is not read as the sample, and neither
# are the parts inside it.
sample_parts.update(id(sample_part) for sample_part in part.walk())
continue
if content_type == "message/feedback-report" and is_feedback_report:
# A feedback report has one message/feedback-report part
# (RFC 6591 §3); a nested one must not be rendered again.
continue
if content_type != "message/feedback-report" and isinstance(
payload_obj[0], email.message.Message
):
# Another message/* body; walk() visits its parts.
continue
payload = str(payload_obj[0])
if content_type == "message/feedback-report":
is_feedback_report = True
# The RFC 6591 part wins over a legacy text report read
# earlier; its sample follows it.
sample = None
decoded_payload = _decode_mime_payload(part, payload)
try:
if "Feedback-Type" in decoded_payload:
feedback_report = decoded_payload
else:
feedback_report = b64decode(decoded_payload).__str__()
feedback_report = feedback_report.lstrip("b'").rstrip("'")
feedback_report = feedback_report.replace("\\r", "")
feedback_report = feedback_report.replace("\\n", "\n")
except (ValueError, TypeError, binascii.Error):
feedback_report = decoded_payload
elif content_type == "application/tlsrpt+json":
if not payload.strip().startswith("{"):
payload = b64decode(payload).decode("utf-8", errors="replace")
smtp_tls_report = parse_smtp_tls_report_json(payload)
return {"report_type": "smtp_tls", "report": smtp_tls_report}
elif content_type == "application/tlsrpt+gzip":
payload = extract_report(payload)
smtp_tls_report = parse_smtp_tls_report_json(payload)
return {"report_type": "smtp_tls", "report": smtp_tls_report}
elif content_type == "text/plain":
# A legacy text report has no message/feedback-report part.
# Once one is read, a text part (which may sit inside the
# sample, the spoofer's own text) must not replace the
# report's Source-IP or its sample.
if (
not is_feedback_report
and "A message claiming to be from you has failed" in payload
):
try:
parts = payload.split("detected.", 1)
field_matches = text_report_regex.findall(parts[0])
fields = dict()
for match in field_matches:
field_name = match[0].lower().replace(" ", "-")
fields[field_name] = match[1].strip()
feedback_report = "Arrival-Date: {}\nSource-IP: {}".format(
fields["received-date"], fields["sender-ip-address"]
)
except Exception as e:
error = f'Unable to parse message with subject "{subject_text}": {e}{_exc_origin(e)}'
raise InvalidDMARCReport(error) from e
sample = parts[1].lstrip()
logger.debug(sample)
else:
try:
payload_bytes = b64decode(payload)
if payload_bytes.startswith(MAGIC_ZIP) or payload_bytes.startswith(
MAGIC_GZIP
):
payload_text = extract_report(payload_bytes)
else:
payload_text = payload_bytes.decode("utf-8", errors="replace")
if payload_text.strip().startswith("{"):
smtp_tls_report = parse_smtp_tls_report_json(payload_text)
result = {"report_type": "smtp_tls", "report": smtp_tls_report}
return result
elif payload_text.strip().startswith("<"):
aggregate_report = parse_aggregate_report_xml(
payload_text,
config=cfg,
keep_alive=keep_alive,
)
result = {
"report_type": "aggregate",
"report": aggregate_report,
}
return result
except (TypeError, ValueError, binascii.Error):
# b64decode() rejected this MIME part's payload as not
# base64-decodable, so it isn't a report attachment; fall
# through and keep walking the message for a part (or the
# feedback-report/sample pair below) that is.
pass
except InvalidDMARCReport as e:
error = f'Message with subject "{subject_text}" is not a valid DMARC report: {e}'
raise InvalidDMARCReport(error) from e
except InvalidSMTPTLSReport:
# Named with the message's subject by the handler below.
raise
except Exception as e:
error = f'Unable to parse message with subject "{subject_text}": {e}{_exc_origin(e)}'
raise ParserError(error) from e
except InvalidSMTPTLSReport as e:
# From either the tlsrpt content-type branches or a JSON attachment.
error = (
f'Message with subject "{subject_text}" is not a valid SMTP TLS report: {e}'
)
raise InvalidSMTPTLSReport(error) from e
except ParserError:
raise
except Exception as e:
error = f'Unable to parse message with subject "{subject_text}": {e}{_exc_origin(e)}'
raise ParserError(error) from e
if feedback_report and sample:
try:
failure_report = parse_failure_report(
feedback_report,
sample,
msg_date,
config=cfg,
)
except InvalidFailureReport as e:
error = (
f'Message with subject "{subject_text}" '
"is not a valid "
f"failure DMARC report: {e}"
)
raise InvalidFailureReport(error) from e
result = {"report_type": "failure", "report": failure_report}
return result
# Every branch that sets ``result`` returns immediately, so reaching this
# point always means no report was found.
error = f'Message with subject "{subject_text}" is not a valid report'
raise InvalidDMARCReport(error)
# An RFC 5322 header field name (printable ASCII excluding the colon) followed
# by a colon at the start of a line, or an mbox "From " separator.
_email_header_regex = re.compile(r"^(From |[\x21-\x39\x3b-\x7e]+:)")
def _looks_like_email(text: str) -> bool:
"""Returns True if the first line looks like an email header.
Callers pass already-``lstrip()``-ed text, so the first line is the first
meaningful line.
"""
first_line = text.split("\n", 1)[0]
return _email_header_regex.match(first_line) is not None
def _leading_text(content: str | bytes) -> str:
"""Return the start of ``content`` (512 characters at most) without
leading whitespace, to sniff its format."""
if isinstance(content, (bytes, bytearray, memoryview)):
sniff = bytes(content)[:512].decode("utf-8", errors="replace")
else:
sniff = content[:512]
return sniff.lstrip()
def _describe_parse_failure(
content: str | bytes,
aggregate_error: str,
smtp_tls_error: str,
email_error: str,
) -> str:
"""Builds a human-readable reason for a parse_report_file failure.
parse_report_file tries the aggregate XML, SMTP TLS JSON, and report-email
parsers in turn; when all three reject the input, only the parser for the
format the content actually resembles produced a meaningful error. The
other two are noise -- a malformed aggregate report is also "not JSON" and
"not an email". Sniff the leading non-whitespace byte to surface the single
relevant reason.
"""
sniff = _leading_text(content)
if sniff.startswith("<"):
return f"Invalid aggregate report: {aggregate_error}"
if sniff.startswith("{"):
return f"Invalid SMTP TLS report: {smtp_tls_error}"
if _looks_like_email(sniff):
return f"Invalid report email: {email_error}"
return (
"Not a recognized report format (not a DMARC aggregate XML report, "
"an SMTP TLS JSON report, or a DMARC report email)"
)
def _read_up_to(file_object: BinaryIO, limit: int) -> bytes:
"""Read from ``file_object`` until end of file or ``limit`` bytes,
whichever comes first. A raw or unbuffered stream (a socket file, a
pipe) may return fewer bytes than asked for without being at its end, so
one sized ``read()`` could hand the parser a truncated report."""
# One growing buffer, not a list of chunks: a stream that returns a byte
# at a time would otherwise cost one bytes object per byte read.
content = bytearray()
while len(content) < limit:
chunk = file_object.read(limit - len(content))
if not chunk:
break
content += chunk
return bytes(content)
def parse_report_file(
input_: bytes | str | os.PathLike[str] | os.PathLike[bytes] | BinaryIO,
*,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
keep_alive: Callable | None = None,
normalize_timespan_threshold_hours: float = 24.0,
config: ParserConfig | None = None,
) -> ParsedReport:
"""Parses a DMARC aggregate report, DMARC failure report, or SMTP TLS
report from a file at the given path, a file-like object, or bytes
A path is opened and closed by this function. A caller-supplied file
object is read from but never closed, on either the success or the
exception path; it is left open and positioned wherever its own
``read()`` left it, so the caller is free to ``seek(0)`` and reuse it.
When the input is over the size cap, that is one byte past the cap: no
more is read.
Args:
input_ (str | os.PathLike | bytes | BinaryIO): A path to a file,
a file-like object, or bytes
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
dns_timeout (float): Sets the DNS timeout in seconds
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map
reverse_dns_map_url (str): URL to a reverse DNS map
offline (bool): Do not make online queries for geolocation or DNS
keep_alive (callable): Keep alive function. Not part of ``config``;
always applies.
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict: The parsed DMARC report
Raises:
ParserError: if the input is not a valid report, or is larger than
``MAX_DECOMPRESSED_REPORT_SIZE`` (refused before it is read into
memory)
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
file_object: BinaryIO
# An input over the cap is refused before it is held in memory (#949). A
# valid compressed report is smaller than its decompressed form, so the
# one cap serves both; the message is the one the content check gives.
too_large = ParserError(_size_cap_text(b""))
if isinstance(input_, (str, os.PathLike)):
file_path = os.fspath(input_)
logger.debug(f"Parsing {file_path}")
if os.path.getsize(file_path) > MAX_DECOMPRESSED_REPORT_SIZE:
raise too_large
# A path is a handle we opened ourselves, so it must be closed on
# both the success and exception paths.
with open(file_path, "rb") as file_object:
content = _read_up_to(file_object, MAX_DECOMPRESSED_REPORT_SIZE + 1)
else:
if isinstance(input_, (bytes, bytearray, memoryview)):
if memoryview(input_).nbytes > MAX_DECOMPRESSED_REPORT_SIZE:
raise too_large
# The BytesIO wrapper is created here, so this function owns
# it and closes it once the bytes have been read out.
file_object = BytesIO(bytes(input_))
content = file_object.read()
file_object.close()
else:
# A caller-supplied file-like object is never closed by this
# function, on success or failure: the caller may want to
# seek(0) and retry, inspect tell(), or otherwise reuse the
# handle afterward.
file_object = input_
content = _read_up_to(file_object, MAX_DECOMPRESSED_REPORT_SIZE + 1)
if len(content) > MAX_DECOMPRESSED_REPORT_SIZE:
raise too_large
if content.startswith(MAGIC_ZIP) or content.startswith(MAGIC_GZIP):
content = extract_report(content)
# parse_report_file tries the three report formats in turn. When all three
# reject the input, keep each format's error text so the final message
# can explain *why* the file is invalid instead of a bare "Not a valid
# report" (see _describe_parse_failure). Only the text is kept: an
# exception's traceback holds the failed parser's frames, and with them
# its partial tree, while the next parser runs (GHSA-q7gm-8cj9-94hr).
aggregate_message = smtp_tls_message = email_message = ""
try:
report = parse_aggregate_report_file(
content,
config=cfg,
keep_alive=keep_alive,
)
return {"report_type": "aggregate", "report": report}
except InvalidAggregateReport as aggregate_error:
aggregate_message = str(aggregate_error)
leading = _leading_text(content)
if leading.startswith("<"):
# JSON never begins with "<", and parse_smtp_tls_report_json() would
# first decode the whole content.
smtp_tls_message = "not JSON"
else:
try:
report = parse_smtp_tls_report_json(content)
return {"report_type": "smtp_tls", "report": report}
except InvalidSMTPTLSReport as smtp_tls_error:
smtp_tls_message = str(smtp_tls_error)
if leading.startswith(("<", "{")):
# An RFC 822 message begins with a header line, never "<" or "{";
# such a file's failure is reported as the aggregate or SMTP TLS
# error, so no email reason is needed.
pass
elif _exceeds_size_cap(content):
# The email parser has no size cap of its own.
email_message = _size_cap_text(content)
else:
try:
return parse_report_email(content, config=cfg, keep_alive=keep_alive)
except InvalidDMARCReport as email_error:
email_message = str(email_error)
message = _describe_parse_failure(
content, aggregate_message, smtp_tls_message, email_message
)
# The cause is a fresh exception with the relevant format's text, so the
# chain is kept without the failed parse's traceback.
if leading.startswith("<"):
raise ParserError(message) from InvalidAggregateReport(aggregate_message)
if leading.startswith("{"):
raise ParserError(message) from InvalidSMTPTLSReport(smtp_tls_message)
raise ParserError(message) from InvalidDMARCReport(email_message)
_ASCII_UPPER_TO_LOWER = str.maketrans(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"
)
def _aggregate_report_dedup_key(report: AggregateReport) -> str:
"""Return the ``seen_aggregate_report_ids`` cache key for ``report``.
RFC 9990 §3.5.1 defines a Report-ID as unique only "among reports to the
same domain", so the key is the reporter, the Report-ID, and the policy
domain. ASCII domain letters are compared without case per RFC 4343 §3,
without changing the domain in the report itself. A JSON array encodes
the three fields without the ambiguity of a delimiter that a field could
contain, and the key is the SHA-256 hex digest of that JSON, so it is 64
characters long whatever the sender puts in the three fields
(GHSA-9fw6-mwqr-p43m: the JSON itself was the key, so a report with a
multi-megabyte Report-ID cost that much memory in the cache).
Every writer of the shared cache must build its key here -- the mbox and
mailbox classifier and the CLI's direct-file path -- or a report read
through one path is not recognized as a duplicate of the same report
read through another.
"""
report_org = report["report_metadata"]["org_name"]
report_id = report["report_metadata"]["report_id"]
report_domain = report["policy_published"]["domain"]
# The existing XML parser returns None for an empty <domain/>.
if isinstance(report_domain, str):
report_domain = report_domain.translate(_ASCII_UPPER_TO_LOWER)
encoded = json.dumps((report_org, report_id, report_domain))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _classify_parsed_email(
parsed_email: ParsedReport,
aggregate_reports: list[AggregateReport],
failure_reports: list[FailureReport],
smtp_tls_reports: list[SMTPTLSReport],
*,
seen_aggregate_report_ids: ExpiringDict,
pending_aggregate_keys: set[str] | None = None,
) -> ReportType:
"""Classify a parsed report email, appending it to the matching list.
Owns the seen-aggregate-report-ID dedup check against
``seen_aggregate_report_ids``: an aggregate report already seen (keyed
on the reporter, the Report-ID, and the reported policy domain) is
logged and dropped instead of appended. Shared, unmodified, by the
sequential and parallel branches of ``get_dmarc_reports_from_mbox`` and
``get_dmarc_reports_from_mailbox`` so both dedup identically -- callers
pass the config's ``seen_aggregate_report_ids`` cache so dedup state
stays scoped to whichever ``ParserConfig`` (explicit or module-default)
is in effect. The key comes from :func:`_aggregate_report_dedup_key`,
which the CLI's direct-file path also uses, so a report seen in a file
and again in an mbox in the same run is recognized as a duplicate.
``pending_aggregate_keys`` lets a caller *defer* the dedup-cache write:
when a set is supplied, a newly seen key is staged in that set instead
of being written to ``seen_aggregate_report_ids``, and the dedup check
consults both. The caller then folds the staged keys into the cache
once the batch is known to have been saved (see
``get_dmarc_reports_from_mailbox``), or drops them so a retry reparses
the same reports rather than silently skipping them as duplicates.
``None`` (the default) keeps the immediate-write behavior.
Returns the report type so mailbox callers know which UID list to
append the source message's UID to.
"""
report_type = parsed_email["report_type"]
# Compare against parsed_email["report_type"] directly (not the
# report_type local above) in each branch so pyright's TypedDict
# discriminated-union narrowing applies to parsed_email["report"].
if parsed_email["report_type"] == "aggregate":
report_org = parsed_email["report"]["report_metadata"]["org_name"]
report_id = parsed_email["report"]["report_metadata"]["report_id"]
report_key = _aggregate_report_dedup_key(parsed_email["report"])
already_seen = report_key in seen_aggregate_report_ids or (
pending_aggregate_keys is not None and report_key in pending_aggregate_keys
)
if not already_seen:
if pending_aggregate_keys is None:
seen_aggregate_report_ids[report_key] = True
else:
pending_aggregate_keys.add(report_key)
aggregate_reports.append(parsed_email["report"])
else:
logger.debug(
"Skipping duplicate aggregate report from "
f"{sanitize_for_log(str(report_org))} "
f"with ID: {sanitize_for_log(str(report_id))}"
)
elif parsed_email["report_type"] == "failure":
failure_reports.append(parsed_email["report"])
elif parsed_email["report_type"] == "smtp_tls":
smtp_tls_reports.append(parsed_email["report"])
return report_type
def _fetch_mailbox_message(
connection: MailboxConnection, msg_uid: Any, test: bool
) -> tuple[int | str, str | ParserError]:
"""Fetch one message from ``connection`` by UID, casting the UID to the
type each backend's ``fetch_message`` expects.
Shared, unmodified, by the sequential and parallel branches of
``get_dmarc_reports_from_mailbox`` so both fetch identically.
Returns ``(message_id, msg_content)``; ``message_id`` is the
backend-appropriate id to use for later move/delete calls.
A backend that re-serializes the stored message while fetching it (the
Maildir backend of mailsuite before 2.3.3 called ``Message.as_string()``;
2.3.3 returns the stored bytes) can fail on the message's own content:
``email.errors.HeaderWriteError`` on a
control character in a header, ``RecursionError`` on deeply nested
multiparts. Those content errors are returned, not raised, as
``msg_content`` -- a ``ParserError`` the caller treats like a parse
failure, disposing of the message as invalid instead of aborting the
batch on every run (GHSA-qxwv-2vwj-v828).
The catch is deliberately narrow (``email.errors.MessageError`` and
``RecursionError``, not ``Exception``): transport and service errors
(``OSError``, IMAP and Microsoft Graph errors, ``httpx`` errors) say
nothing about the message and still propagate, because classifying a
transient network failure as invalid content would move a legitimate
report to the invalid folder.
"""
message_id: int | str
if isinstance(connection, IMAPConnection):
message_id = int(msg_uid)
else:
message_id = str(msg_uid) if not isinstance(msg_uid, str) else msg_uid
try:
if isinstance(connection, (MSGraphConnection, MaildirConnection)):
msg_content = connection.fetch_message(message_id, mark_read=not test)
else:
msg_content = connection.fetch_message(message_id)
except (email.errors.MessageError, RecursionError) as e:
return message_id, ParserError(
f"Unable to fetch message UID {message_id}: {type(e).__name__}: {e}"
)
return message_id, msg_content
def _dispose_invalid_message(
connection: MailboxConnection,
message_id: int | str,
delete: bool,
invalid_reports_folder: str,
) -> bool:
"""Delete or move an unparseable message, per ``delete``.
Callers pass their effective ``delete_invalid`` value as ``delete``.
Returns whether the message left the reports folder.
Shared, unmodified, by the sequential and parallel branches of
``get_dmarc_reports_from_mailbox`` so both dispose of invalid messages
identically.
A delete or move the backend rejects is logged and the message is left
in place, as the archival disposal of parsed reports does, rather than
aborting the rest of the batch. Besides transient backend errors, this
covers messages the backend cannot rewrite: the Maildir backend of
mailsuite before 2.3.3 moved a message by storing it again with
``mailbox.Maildir.add()``, which re-serializes it and raises
``RecursionError`` for deeply nested multiparts (and
``email.errors.HeaderWriteError`` for a control character in a header
where ``BytesGenerator`` verifies headers), so such a message stayed in
the inbox. mailsuite 2.3.3 renames the file instead
(GHSA-qxwv-2vwj-v828, GHSA-9jx3-p8wm-g8vw).
"""
try:
if delete:
logger.debug(f"Deleting message UID {message_id}")
if isinstance(connection, IMAPConnection):
connection.delete_message(int(message_id))
else:
connection.delete_message(str(message_id))
else:
logger.debug(f"Moving message UID {message_id} to {invalid_reports_folder}")
if isinstance(connection, IMAPConnection):
connection.move_message(int(message_id), invalid_reports_folder)
else:
connection.move_message(str(message_id), invalid_reports_folder)
except Exception as e:
action = "deleting" if delete else "moving"
logger.error(
f"Mailbox error: Error {action} invalid message UID {message_id}: "
f"{type(e).__name__}: {e}"
)
return False
return True
def _record_parse_attempt(
attempt_store: AttemptStore,
key: str,
message_id: int | str,
reports_folder: str,
delete_invalid: bool,
invalid_reports_folder: str,
test: bool,
) -> bool:
"""Record that a message is about to be parsed; return ``True`` when it
must be set aside instead.
A message whose earlier parse attempts never finished
``MAX_PARSE_ATTEMPTS`` times is taken to be what killed those runs: the
error is logged here, and the caller disposes of the message as invalid
without parsing it (or, under ``test``, leaves it in place). Such a
message is not recorded again, so a message left in place by a failed
move or by ``test`` keeps the same count on every later run.
"""
count = attempt_store.count(key)
if not attempt_store.enabled or count < MAX_PARSE_ATTEMPTS:
attempt_store.record(key)
return False
if test:
action = "Leaving it in place without parsing it (test mode)"
elif delete_invalid:
action = "Deleting it without parsing it"
else:
action = f"Moving it to {invalid_reports_folder} without parsing it"
logger.error(
f"Message UID {message_id} in {reports_folder} has {count} unfinished "
f"parse attempts; the limit is {MAX_PARSE_ATTEMPTS}. {action}"
)
return True
def get_dmarc_reports_from_mbox(
input_: str,
*,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
normalize_timespan_threshold_hours: float = 24.0,
n_procs: int = 1,
parse_timeout: float = 0.0,
config: ParserConfig | None = None,
) -> ParsingResults:
"""Parses a mailbox in mbox format containing e-mails with attached
DMARC and SMTP TLS reports
Args:
input_ (str): A path to a mbox file
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
dns_timeout (float): Sets the DNS timeout in seconds
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
offline (bool): Do not make online queries for geolocation or DNS
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
n_procs (int): Number of processes to use for parsing messages in
parallel. Message reading, deduplication, and result assembly
stay in the calling process; only parsing is parallelized. Not
part of ``config``; always applies.
parse_timeout (float): Stop parsing a message after this many
seconds and skip it with a warning, as an invalid message
(``0``, the default, means no limit). When set, messages are
parsed in worker processes even with ``n_procs`` 1. A negative
or non-finite value raises ``ValueError``. See
``parsedmarc.parallel.parallel_map()``. Not part of
``config``; always applies.
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, the
individual option keyword arguments listed above are ignored in
favor of the config's values.
Returns:
dict: Lists of ``aggregate_reports``, ``failure_reports``, and ``smtp_tls_reports``
"""
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
aggregate_reports: list[AggregateReport] = []
failure_reports: list[FailureReport] = []
smtp_tls_reports: list[SMTPTLSReport] = []
try:
mbox = mailbox.mbox(input_)
message_keys = mbox.keys()
total_messages = len(message_keys)
logger.debug(f"Found {total_messages} messages in {input_}")
if parse_timeout or (n_procs > 1 and total_messages > 1):
from parsedmarc.parallel import (
_email_timeout_result,
_parse_report_email_job,
parallel_map,
)
func = functools.partial(_parse_report_email_job, config=cfg)
def _jobs():
for i in range(total_messages):
message_key = message_keys[i]
logger.info(f"Processing message {i + 1} of {total_messages}")
yield mbox.get_bytes(message_key)
for result in tqdm(
parallel_map(
func,
_jobs(),
n_procs,
parse_timeout=parse_timeout,
timeout_result=_email_timeout_result,
),
total=total_messages,
disable=None,
):
if isinstance(result, (InvalidDMARCReport, InvalidSMTPTLSReport)):
logger.warning(str(result))
elif isinstance(result, ParserError):
raise result
else:
_classify_parsed_email(
result,
aggregate_reports,
failure_reports,
smtp_tls_reports,
seen_aggregate_report_ids=cfg.seen_aggregate_report_ids,
)
else:
for i in tqdm(range(total_messages), disable=None):
message_key = message_keys[i]
logger.info(f"Processing message {i + 1} of {total_messages}")
msg_content = mbox.get_bytes(message_key)
try:
parsed_email = parse_report_email(msg_content, config=cfg)
_classify_parsed_email(
parsed_email,
aggregate_reports,
failure_reports,
smtp_tls_reports,
seen_aggregate_report_ids=cfg.seen_aggregate_report_ids,
)
except (InvalidDMARCReport, InvalidSMTPTLSReport) as error:
logger.warning(error.__str__())
except mailbox.NoSuchMailboxError:
raise InvalidDMARCReport(f"Mailbox {input_} does not exist")
return {
"aggregate_reports": aggregate_reports,
"failure_reports": failure_reports,
"smtp_tls_reports": smtp_tls_reports,
}
def _migrate_forensic_archive_folder(
connection: MailboxConnection, archive_folder: str
) -> None:
"""Consolidate a pre-rename ``<archive>/Forensic`` subfolder into
``<archive>/Failure``.
Before failure reports were renamed from "forensic" reports, they were
archived under ``<archive_folder>/Forensic``; they now go to
``<archive_folder>/Failure``. This best-effort, run-on-startup migration
moves any pre-existing legacy archive into the new location so reports
filed before and after the rename live in the same folder.
It is a no-op when there is no legacy ``Forensic`` folder (the common
case), and never raises: a mailbox that cannot be reorganized is logged
and skipped, consistent with the rest of parsedmarc's mailbox handling
(warn, don't crash). Uses the folder-management API added in mailsuite
2.1.0 (``folder_exists`` / ``rename_folder`` / ``merge_folders``).
"""
old_folder = f"{archive_folder}/Forensic"
new_folder = f"{archive_folder}/Failure"
try:
if not connection.folder_exists(old_folder):
return
if connection.folder_exists(new_folder):
# Both exist (e.g. a partial earlier migration, or a manually
# created Failure folder): move the legacy folder's messages into
# the new one and drop the now-empty legacy folder.
connection.merge_folders(old_folder, new_folder)
logger.info(f"Merged legacy archive folder {old_folder} into {new_folder}")
else:
connection.rename_folder(old_folder, new_folder)
logger.info(f"Renamed legacy archive folder {old_folder} to {new_folder}")
except Exception as error:
logger.warning(
f"Could not migrate legacy archive folder {old_folder} to {new_folder}: {error}"
)
def _ensure_folder(connection: MailboxConnection, folder: str) -> None:
"""Best-effort create ``folder`` if the backend says it is missing.
``get_dmarc_reports_from_mailbox()`` creates its destination folders up
front, but only when ``create_folders`` is set -- watch mode calls it
with ``create_folders=False``, and the ``Unsaved`` holding folder is
only created up front when a ``save_callback`` was supplied. This is the
defensive check immediately before a message is moved there.
Like ``_migrate_forensic_archive_folder``, it never raises: a backend
that cannot report on or create the folder is logged and skipped, and
the move that follows either succeeds anyway (the folder already
existed) or fails and is logged by its own error handler -- warn, don't
crash.
"""
try:
if not connection.folder_exists(folder):
connection.create_folder(folder)
except Exception as error:
logger.warning(f"Could not create folder {folder}: {error}")
def get_dmarc_reports_from_mailbox(
connection: MailboxConnection,
*,
reports_folder: str = "INBOX",
archive_folder: str = "Archive",
delete: bool = False,
delete_aggregate: bool | None = None,
delete_failure: bool | None = None,
delete_smtp_tls: bool | None = None,
delete_invalid: bool | None = None,
test: bool = False,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
results: ParsingResults | None = None,
batch_size: int = 10,
since: datetime | date | str | None = None,
create_folders: bool = True,
normalize_timespan_threshold_hours: float = 24.0,
n_procs: int = 1,
parse_timeout: float = 0.0,
save_callback: Callable[[ParsingResults], bool | None] | None = None,
max_unsaved_retries: int = 2,
attempts_file: str | None = None,
config: ParserConfig | None = None,
) -> ParsingResults:
"""
Fetches and parses DMARC and SMTP TLS reports from a mailbox
Args:
connection: A Mailbox connection object
reports_folder (str): The folder where reports can be found
archive_folder (str): The folder to move processed mail to
delete (bool): Delete messages after processing them
delete_aggregate (bool | None): Delete aggregate report messages
after processing them, instead of moving them to the
``Aggregate`` archive subfolder; ``None`` (the default) inherits
the value of ``delete``
delete_failure (bool | None): Delete failure report messages after
processing them, instead of moving them to the ``Failure``
archive subfolder; ``None`` (the default) inherits the value of
``delete``
delete_smtp_tls (bool | None): Delete SMTP TLS report messages after
processing them, instead of moving them to the ``SMTP-TLS``
archive subfolder; ``None`` (the default) inherits the value of
``delete``
delete_invalid (bool | None): Delete unparseable messages, instead
of moving them to the ``Invalid`` archive subfolder where they
can be inspected for debugging; ``None`` (the default) inherits
the value of ``delete``
test (bool): Do not move or delete messages after processing them
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
offline (bool): Do not query online for geolocation or DNS
nameservers (list): A list of DNS nameservers to query
dns_timeout (float): Set the DNS query timeout
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
results (dict): Results from the previous run
batch_size (int): Number of messages to read and process before saving
(use 0 for no limit)
since: Search for messages since a certain time
(units - {"m":"minutes", "h":"hours", "d":"days", "w":"weeks"})
create_folders (bool): Whether to create the destination folders
(not used in watch)
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
n_procs (int): Number of processes to use for parsing messages in
parallel. Fetching, archiving, and deduplication remain
sequential in the calling process; only parsing is
parallelized. With ``n_procs > 1``, invalid-message disposition
happens after the parsing phase completes, rather than
interleaved message-by-message as it is when ``n_procs`` is 1.
Not part of ``config``; always applies.
parse_timeout (float): Stop parsing a message after this many
seconds and move it to the ``Invalid`` archive subfolder, as an
invalid message (``0``, the default, means no limit). When set,
messages are parsed in worker processes even with ``n_procs``
1, so disposition of invalid messages happens after the parsing
phase as it does with ``n_procs > 1``. A negative or non-finite
value raises ``ValueError``. See
``parsedmarc.parallel.parallel_map()``. Not part of
``config``; always applies.
save_callback: An optional callable invoked once per fetched batch
with a ``ParsingResults`` dict holding only that batch's newly
parsed reports, after parsing but before any of the batch's
messages are deleted or moved out of ``reports_folder``. It
tells this function whether the batch was actually persisted:
* Returning ``False`` means "not saved": the batch's messages
are left in ``reports_folder`` for retry on the next run (or
moved to ``{archive_folder}/Unsaved`` once they have failed
``max_unsaved_retries`` retries -- see below), and the
aggregate-report dedup cache is not updated for that batch, so
a retry reparses the same reports instead of skipping them as
duplicates.
* Raising counts as "not saved" too: the same bookkeeping runs
(the batch's messages are held back or moved to ``Unsaved`` at
the cap, and the failed attempt counts toward
``max_unsaved_retries``), and the exception is then re-raised
to the caller.
* Any other return value, including ``None``, commits the batch:
the dedup cache is updated and the messages are deleted or
archived exactly as they are with no callback.
``None`` (the default) commits every batch, preserving the prior
behavior. The callback is still invoked when ``test`` is
``True``, so a test run exercises the full pipeline, but neither
the mailbox nor the retry counters are touched regardless of
what it returns.
max_unsaved_retries (int): How many times a message may be *retried*
after ``save_callback`` first reported its batch unsaved, before
it is moved to the ``Unsaved`` archive subfolder instead of
being retried again (default 2, i.e. the initial attempt plus
two retries -- at most three deliveries to any output
destination that does not deduplicate). ``0`` moves a message on
the first failed save; negative values raise ``ValueError``.
Messages moved to ``Unsaved`` are never deleted, whatever the
``delete`` options say; recover them by fixing the output
destination and moving them back into ``reports_folder``.
Counts are kept in memory, per message and
per process, are reset by a successful save, and are only kept
when a ``save_callback`` is supplied -- so the cap applies
across repeated calls within one process (``watch_inbox()``'s
checks), not across separate one-shot processes, each of which
starts a message's count over. Mailbox orchestration, so like
``batch_size`` it is not part of ``config``.
attempts_file (str | None): A JSON file in which to count, per
message, the parse attempts that started but never finished,
because the process died while parsing (the out-of-memory
killer, a container memory cap). An attempt is recorded before
a message is parsed and cleared as soon as the parse returns a
report or raises ``ParserError``, so a failed save is not
counted. A message that has died ``MAX_PARSE_ATTEMPTS`` (2)
times is logged at error level and disposed of as invalid
(moved to ``Invalid``, or deleted under ``delete_invalid``)
without being parsed again. With ``n_procs > 1`` or
``parse_timeout`` set, a message that was being parsed when an
earlier run died is parsed on its own after the others (in this process, or with
``parse_timeout`` in a worker pool of its own, so the timeout
still applies), so the messages parsed beside it are not blamed
for it. ``None`` (the default) keeps
no record. Mailbox orchestration, not part of ``config``.
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, it replaces
the individual parsing and enrichment option keyword arguments
listed above (DNS, GeoIP, offline mode, attachment payload
stripping, timespan normalization), whose values are then
ignored. The remaining keyword arguments control mailbox
handling and orchestration rather than parsing (the folder
names, the ``delete`` options, ``test``, ``since``,
``batch_size``, ``save_callback``, ``max_unsaved_retries``,
``attempts_file``); they are not part of ``config`` and always
apply.
Returns:
dict: Lists of ``aggregate_reports``, ``failure_reports``, and ``smtp_tls_reports``
"""
# Each per-report-type flag inherits the overall ``delete`` value when it
# is left unset (``None``). Resolve once, up front: every decision below
# reads only these four resolved flags. The raw ``delete`` parameter is
# still forwarded verbatim to the recursive self-call at the end of this
# function, where it is inert because all four resolved flags accompany
# it.
delete_aggregate = delete if delete_aggregate is None else delete_aggregate
delete_failure = delete if delete_failure is None else delete_failure
delete_smtp_tls = delete if delete_smtp_tls is None else delete_smtp_tls
delete_invalid = delete if delete_invalid is None else delete_invalid
if test and (
delete_aggregate or delete_failure or delete_smtp_tls or delete_invalid
):
raise ValueError("delete options and test are mutually exclusive")
if max_unsaved_retries < 0:
raise ValueError("max_unsaved_retries must be >= 0")
from parsedmarc.parallel import _check_parse_timeout
_check_parse_timeout(parse_timeout)
if connection is None:
raise ValueError("Must supply a connection")
attempt_store = AttemptStore(attempts_file) if attempts_file else None
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
# current_time useful to fetch_messages later in the program
current_time: datetime | date | str | None = None
aggregate_reports: list[AggregateReport] = []
failure_reports: list[FailureReport] = []
smtp_tls_reports: list[SMTPTLSReport] = []
# This call's own reports, kept separate from the accumulated lists
# above (which carry earlier batches' reports in via ``results``) so the
# save callback is handed only what this batch parsed.
batch_aggregate_reports: list[AggregateReport] = []
batch_failure_reports: list[FailureReport] = []
batch_smtp_tls_reports: list[SMTPTLSReport] = []
# Aggregate dedup keys are staged here and only written to the shared
# cache once the batch is known to be saved, so an unsaved batch (or a
# mid-batch crash) leaves the cache clean and the reports are reparsed
# on the retry instead of being dropped as duplicates.
pending_aggregate_keys: set[str] = set()
aggregate_report_msg_uids = []
failure_report_msg_uids = []
smtp_tls_msg_uids = []
aggregate_reports_folder = f"{archive_folder}/Aggregate"
failure_reports_folder = f"{archive_folder}/Failure"
smtp_tls_reports_folder = f"{archive_folder}/SMTP-TLS"
invalid_reports_folder = f"{archive_folder}/Invalid"
unsaved_reports_folder = f"{archive_folder}/Unsaved"
if results:
aggregate_reports = results["aggregate_reports"].copy()
failure_reports = results["failure_reports"].copy()
smtp_tls_reports = results["smtp_tls_reports"].copy()
if not test and create_folders:
_migrate_forensic_archive_folder(connection, archive_folder)
connection.create_folder(archive_folder)
connection.create_folder(aggregate_reports_folder)
connection.create_folder(failure_reports_folder)
connection.create_folder(smtp_tls_reports_folder)
connection.create_folder(invalid_reports_folder)
if save_callback is not None:
# Only reachable when a callback can report a batch unsaved;
# without one no message is ever held back, so the folder would
# sit empty in every mailbox.
connection.create_folder(unsaved_reports_folder)
if since and isinstance(since, str):
_since = 1440 # default one day
if re.match(r"\d+[mhdw]$", since):
s = re.split(r"(\d+)", since)
if s[2] == "m":
_since = int(s[1])
elif s[2] == "h":
_since = int(s[1]) * 60
elif s[2] == "d":
_since = int(s[1]) * 60 * 24
elif s[2] == "w":
_since = int(s[1]) * 60 * 24 * 7
else:
logger.warning(
f"Incorrect format for 'since' option. Provided value: {since}, "
"expected values: (5m|3h|2d|1w). Ignoring option, fetching "
"messages for last 24hrs. SMTP does not support a time or "
"timezone in since. See "
"https://www.rfc-editor.org/rfc/rfc3501#page-52"
)
if isinstance(connection, IMAPConnection):
logger.debug(
"Only days and weeks values in 'since' option are considered "
"for IMAP connections. Examples: 2d or 1w"
)
since = (datetime.now(timezone.utc) - timedelta(minutes=_since)).strftime(
"%d-%b-%Y"
)
current_time = datetime.now(timezone.utc).strftime("%d-%b-%Y")
elif isinstance(connection, MSGraphConnection):
since = (datetime.now(timezone.utc) - timedelta(minutes=_since)).isoformat()
current_time = datetime.now(timezone.utc).isoformat()
elif isinstance(connection, GmailConnection):
since = (datetime.now(timezone.utc) - timedelta(minutes=_since)).strftime(
"%s"
)
current_time = datetime.now(timezone.utc).strftime("%s")
else:
pass
messages = connection.fetch_messages(
reports_folder, batch_size=batch_size, since=since
)
total_messages = len(messages)
logger.debug(f"Found {len(messages)} messages in {reports_folder}")
if batch_size and not since:
message_limit = min(total_messages, batch_size)
else:
message_limit = total_messages
logger.debug(f"Processing {message_limit} messages")
def _parse_finished(key: str | None) -> None:
# A parse that returned a result or raised ParserError did not
# crash the process, so its attempt is cleared before anything that
# can fail for other reasons (the save, the move) runs.
if attempt_store is not None and key is not None:
attempt_store.clear(key)
def _parse_in_process(
message_id: int | str, msg_content: str | ParserError
) -> None:
key: str | None = None
try:
if isinstance(msg_content, ParserError):
raise msg_content
if attempt_store is not None:
key = attempt_key(reports_folder, message_id, msg_content)
if _record_parse_attempt(
attempt_store,
key,
message_id,
reports_folder,
delete_invalid,
invalid_reports_folder,
test,
):
# The entry is kept until the message has left the
# folder, so a failed move is retried on the next run.
if not test and _dispose_invalid_message(
connection, message_id, delete_invalid, invalid_reports_folder
):
attempt_store.clear(key)
return
parsed_email = parse_report_email(
msg_content,
config=cfg,
keep_alive=connection.keepalive,
)
_parse_finished(key)
report_type = _classify_parsed_email(
parsed_email,
batch_aggregate_reports,
batch_failure_reports,
batch_smtp_tls_reports,
seen_aggregate_report_ids=cfg.seen_aggregate_report_ids,
pending_aggregate_keys=pending_aggregate_keys,
)
if report_type == "aggregate":
aggregate_report_msg_uids.append(message_id)
elif report_type == "failure":
failure_report_msg_uids.append(message_id)
elif report_type == "smtp_tls":
smtp_tls_msg_uids.append(message_id)
except ParserError as error:
_parse_finished(key)
logger.warning(error.__str__())
if not test:
_dispose_invalid_message(
connection, message_id, delete_invalid, invalid_reports_folder
)
if parse_timeout or (n_procs > 1 and message_limit > 1):
from parsedmarc.parallel import (
_email_timeout_result,
_parse_report_email_job,
parallel_map,
)
# The config's caches (ip_address_cache, seen_aggregate_report_ids,
# reverse_dns_map) never cross the process boundary --
# ParserConfig.__getstate__ drops them, and each worker accumulates
# its own via the module defaults it rebinds to on unpickling (see
# ParserConfig.__setstate__). keep_alive is a bound method of the
# live connection object and is not a ParserConfig field, so it is
# never submitted to the pool either; the heartbeat passed to
# parallel_map below keeps the connection alive instead.
func = functools.partial(_parse_report_email_job, config=cfg)
# parallel_map yields results in submission order, so the oldest
# queued id always belongs to the next yielded result; popping as
# results arrive keeps this queue no larger than the in-flight
# submission window. Each id travels with its attempts-file key
# (None when there is no attempts file, or no attempt to clear).
fetched_ids: deque[tuple[int | str, str | None]] = deque()
invalid_msg_ids: list[tuple[int | str, str | None]] = []
# Messages that were being parsed when an earlier run died. That
# run had up to a whole submission window in flight (with n_procs
# above 1, or with parse_timeout set, which takes this path even
# with one process), so all of them are suspects; parsing each alone, after the others, lets the next
# crash count against the one message that caused it.
suspects: list[tuple[int | str, str]] = []
def _jobs():
for i in range(message_limit):
msg_uid = messages[i]
logger.debug(
f"Processing message {i + 1} of {message_limit}: UID {msg_uid}"
)
message_id, msg_content = _fetch_mailbox_message(
connection, msg_uid, test
)
if isinstance(msg_content, ParserError):
# Never submitted, so never yielded and never queued in
# fetched_ids; the post-loop disposal handles it.
logger.warning(str(msg_content))
invalid_msg_ids.append((message_id, None))
continue
key: str | None = None
if attempt_store is not None:
key = attempt_key(reports_folder, message_id, msg_content)
if 0 < attempt_store.count(key) < MAX_PARSE_ATTEMPTS:
logger.warning(
f"Message UID {message_id} in {reports_folder} "
"was being parsed when parsedmarc last stopped "
"without finishing; parsing it on its own after "
"the rest of the batch"
)
suspects.append((message_id, msg_content))
continue
if _record_parse_attempt(
attempt_store,
key,
message_id,
reports_folder,
delete_invalid,
invalid_reports_folder,
test,
):
invalid_msg_ids.append((message_id, key))
continue
fetched_ids.append((message_id, key))
yield msg_content
def _take_result(
message_id: int | str,
key: str | None,
result: ParsedReport | ParserError,
) -> None:
_parse_finished(key)
if isinstance(result, ParserError):
logger.warning(str(result))
invalid_msg_ids.append((message_id, None))
else:
report_type = _classify_parsed_email(
result,
batch_aggregate_reports,
batch_failure_reports,
batch_smtp_tls_reports,
seen_aggregate_report_ids=cfg.seen_aggregate_report_ids,
pending_aggregate_keys=pending_aggregate_keys,
)
if report_type == "aggregate":
aggregate_report_msg_uids.append(message_id)
elif report_type == "failure":
failure_report_msg_uids.append(message_id)
elif report_type == "smtp_tls":
smtp_tls_msg_uids.append(message_id)
for result in parallel_map(
func,
_jobs(),
n_procs,
heartbeat=connection.keepalive,
parse_timeout=parse_timeout,
timeout_result=_email_timeout_result,
):
message_id, key = fetched_ids.popleft()
_take_result(message_id, key, result)
for message_id, msg_content in suspects:
if not parse_timeout or attempt_store is None:
_parse_in_process(message_id, msg_content)
continue
# Only a worker process can be stopped when the timeout expires,
# so each suspect gets a pool of its own: the timeout still
# bounds it, and a crash is still charged to it alone. A suspect
# is below MAX_PARSE_ATTEMPTS, so this record cannot reach the
# cap.
key = attempt_key(reports_folder, message_id, msg_content)
attempt_store.record(key)
for result in parallel_map(
func,
[msg_content],
1,
heartbeat=connection.keepalive,
parse_timeout=parse_timeout,
timeout_result=_email_timeout_result,
):
_take_result(message_id, key, result)
if not test:
for invalid_message_id, key in invalid_msg_ids:
# A set-aside message's entry is cleared only once it has
# left the folder, as in _parse_in_process.
if _dispose_invalid_message(
connection,
invalid_message_id,
delete_invalid,
invalid_reports_folder,
):
_parse_finished(key)
else:
for i in range(message_limit):
msg_uid = messages[i]
logger.debug(
f"Processing message {i + 1} of {message_limit}: UID {msg_uid}"
)
message_id, msg_content = _fetch_mailbox_message(connection, msg_uid, test)
_parse_in_process(message_id, msg_content)
# Ask the caller whether this batch actually made it to its output
# destinations before touching a single message. A callback that says
# otherwise (or raises) means the reports exist nowhere else yet, so no
# message may be archived or deleted -- only retained for retry, or moved
# intact to the Unsaved folder once retries run out (#242).
batch_results: ParsingResults = {
"aggregate_reports": batch_aggregate_reports,
"failure_reports": batch_failure_reports,
"smtp_tls_reports": batch_smtp_tls_reports,
}
persisted = True
callback_error: Exception | None = None
if save_callback is not None:
try:
persisted = save_callback(batch_results) is not False
except Exception as error:
# A raising callback is a failed save too: the failure
# bookkeeping below still runs (count the attempt, hold or shelve
# the messages) before the exception is re-raised, so a callback
# that always raises -- e.g. the CLI's under
# ``fail_on_output_error`` -- is still bounded by
# ``max_unsaved_retries`` even when the caller's watch loop
# swallows the exception and keeps checking, as mailsuite's IMAP
# and Maildir backends do.
persisted = False
callback_error = error
batch_msg_uids = (
aggregate_report_msg_uids + failure_report_msg_uids + smtp_tls_msg_uids
)
if persisted:
# Committing: the reports are safely stored elsewhere, so record
# their dedup keys and forget any earlier failures for these
# messages.
for report_key in pending_aggregate_keys:
cfg.seen_aggregate_report_ids[report_key] = True
if not test:
for msg_uid in batch_msg_uids:
_FAILED_SAVE_ATTEMPTS.pop((reports_folder, str(msg_uid)), None)
elif not test:
# Held back for retry. Messages that have now failed the initial
# attempt plus ``max_unsaved_retries`` retries stop being retried and
# move to the Unsaved folder, bounding duplicate delivery to output
# destinations that do not deduplicate; the rest stay put.
retained_uids: list[int | str] = []
over_cap_uids: list[int | str] = []
highest_attempt = 0
for msg_uid in batch_msg_uids:
attempts_key = (reports_folder, str(msg_uid))
attempts = _FAILED_SAVE_ATTEMPTS.get(attempts_key, 0) + 1
_FAILED_SAVE_ATTEMPTS[attempts_key] = attempts
if attempts > max_unsaved_retries:
over_cap_uids.append(msg_uid)
else:
retained_uids.append(msg_uid)
highest_attempt = max(highest_attempt, attempts)
if retained_uids:
logger.error(
f"Reports were not saved: leaving {len(retained_uids)} "
f"message(s) in {reports_folder} to retry on the next run "
f"or check (failed attempt {highest_attempt} of "
f"{max_unsaved_retries + 1})"
)
if over_cap_uids:
logger.error(
f"Reports were not saved after {max_unsaved_retries + 1} "
f"attempt(s): moving {len(over_cap_uids)} message(s) from "
f"{reports_folder} to {unsaved_reports_folder} instead of "
"retrying them further. They are never deleted -- fix the "
f"output destination, then move them back to {reports_folder}"
)
_ensure_folder(connection, unsaved_reports_folder)
for msg_uid in over_cap_uids:
try:
connection.move_message(msg_uid, unsaved_reports_folder)
except Exception as e:
e = f"Error moving message UID {msg_uid}: {e}"
logger.error(f"Mailbox error: {e}")
else:
# Drop the counter only once the message is actually out
# of the retry loop. Clearing it before a failed move
# would hand the still-in-place message a fresh set of
# under-cap retries (and deliveries); keeping it means
# the next failed save classifies the message over-cap
# again and re-attempts the move instead.
_FAILED_SAVE_ATTEMPTS.pop((reports_folder, str(msg_uid)), None)
if callback_error is not None:
raise callback_error
aggregate_reports += batch_aggregate_reports
failure_reports += batch_failure_reports
smtp_tls_reports += batch_smtp_tls_reports
if persisted and not test:
# Each report type is disposed of according to its own effective
# delete flag: deleted outright, or moved to its archive subfolder.
for msg_uids, delete_type, destination_folder, label in (
(
aggregate_report_msg_uids,
delete_aggregate,
aggregate_reports_folder,
"aggregate report",
),
(
failure_report_msg_uids,
delete_failure,
failure_reports_folder,
"failure report",
),
(
smtp_tls_msg_uids,
delete_smtp_tls,
smtp_tls_reports_folder,
"SMTP TLS report",
),
):
number_of_msgs = len(msg_uids)
if number_of_msgs == 0:
continue
if not delete_type:
message = f"Moving {label} messages from"
logger.debug(f"{message} {reports_folder} to {destination_folder}")
for i in range(number_of_msgs):
msg_uid = msg_uids[i]
if delete_type:
logger.debug(
f"Deleting message {i + 1} of {number_of_msgs}: UID {msg_uid}"
)
try:
connection.delete_message(msg_uid)
except Exception as e:
message = "Error deleting message UID"
e = f"{message} {msg_uid}: {e}"
logger.error(f"Mailbox error: {e}")
else:
message = "Moving message"
logger.debug(
f"{message} {i + 1} of {number_of_msgs}: UID {msg_uid}"
)
try:
connection.move_message(msg_uid, destination_folder)
except Exception as e:
e = f"Error moving message UID {msg_uid}: {e}"
logger.error(f"Mailbox error: {e}")
results = {
"aggregate_reports": aggregate_reports,
"failure_reports": failure_reports,
"smtp_tls_reports": smtp_tls_reports,
}
# An unsaved batch left its messages in ``reports_folder``, so the
# re-check below would find them again and immediately reprocess the
# very messages that just failed -- burning through the retry cap in one
# call. Skip it and let the next run retry them.
if persisted and not test and not batch_size:
if current_time:
total_messages = len(
connection.fetch_messages(reports_folder, since=current_time)
)
else:
total_messages = len(connection.fetch_messages(reports_folder))
else:
total_messages = 0
if total_messages > 0:
# Process emails that came in during the last run
results = get_dmarc_reports_from_mailbox(
connection=connection,
reports_folder=reports_folder,
archive_folder=archive_folder,
delete=delete,
delete_aggregate=delete_aggregate,
delete_failure=delete_failure,
delete_smtp_tls=delete_smtp_tls,
delete_invalid=delete_invalid,
test=test,
results=results,
since=current_time,
n_procs=n_procs,
parse_timeout=parse_timeout,
save_callback=save_callback,
max_unsaved_retries=max_unsaved_retries,
attempts_file=attempts_file,
config=cfg,
)
return results
def watch_inbox(
mailbox_connection: MailboxConnection,
callback: Callable,
*,
reports_folder: str = "INBOX",
archive_folder: str = "Archive",
delete: bool = False,
delete_aggregate: bool | None = None,
delete_failure: bool | None = None,
delete_smtp_tls: bool | None = None,
delete_invalid: bool | None = None,
test: bool = False,
check_timeout: int = 30,
ip_db_path: str | None = None,
always_use_local_files: bool = False,
reverse_dns_map_path: str | None = None,
reverse_dns_map_url: str | None = None,
offline: bool = False,
nameservers: list[str] | None = None,
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
strip_attachment_payloads: bool = False,
batch_size: int = 10,
since: datetime | date | str | None = None,
normalize_timespan_threshold_hours: float = 24.0,
config_reloading: Callable | None = None,
n_procs: int = 1,
parse_timeout: float = 0.0,
max_unsaved_retries: int = 2,
attempts_file: str | None = None,
config: ParserConfig | None = None,
):
"""
Watches the mailbox for new messages and sends the results to a
callback function
Args:
mailbox_connection: The mailbox connection object
callback: The callback function to receive the parsing results.
Passed straight through to ``get_dmarc_reports_from_mailbox()``
as its ``save_callback``, so it now runs once per fetched batch,
with only that batch's reports, *before* those messages are
deleted or moved out of ``reports_folder`` -- rather than once
afterward with the whole check's accumulated results. Returning
``False`` reports the batch as unsaved, leaving its messages in
place to be retried on the next check instead of archived or
deleted (see ``save_callback`` and ``max_unsaved_retries`` on
``get_dmarc_reports_from_mailbox()``). Raising counts as an
unsaved batch too -- same retention and retry cap -- before the
exception reaches the mailbox backend's watch loop; what happens
then is backend-specific: the Microsoft Graph and Gmail backends
let it propagate and end the watch, while mailsuite's IMAP and
Maildir watch loops log it and keep checking.
reports_folder (str): The folder where reports can be found
archive_folder (str): The folder to move processed mail to
delete (bool): Delete messages after processing them
delete_aggregate (bool | None): Delete aggregate report messages
after processing them, instead of moving them to the
``Aggregate`` archive subfolder; ``None`` (the default) inherits
the value of ``delete``
delete_failure (bool | None): Delete failure report messages after
processing them, instead of moving them to the ``Failure``
archive subfolder; ``None`` (the default) inherits the value of
``delete``
delete_smtp_tls (bool | None): Delete SMTP TLS report messages after
processing them, instead of moving them to the ``SMTP-TLS``
archive subfolder; ``None`` (the default) inherits the value of
``delete``
delete_invalid (bool | None): Delete unparseable messages, instead
of moving them to the ``Invalid`` archive subfolder where they
can be inspected for debugging; ``None`` (the default) inherits
the value of ``delete``
test (bool): Do not move or delete messages after processing them
check_timeout (int): Number of seconds to wait for an IMAP IDLE
response or the number of seconds until the next mail check
ip_db_path (str): Path to a MMDB file from IPinfo, MaxMind, or DBIP
always_use_local_files (bool): Do not download files
reverse_dns_map_path (str): Path to a reverse DNS map file
reverse_dns_map_url (str): URL to a reverse DNS map file
offline (bool): Do not query online for geolocation or DNS
nameservers (list): A list of one or more nameservers to use
(Cloudflare's public DNS resolvers by default)
dns_timeout (float): Set the DNS query timeout
dns_retries (int): Number of times to retry DNS queries on timeout
or other transient errors
strip_attachment_payloads (bool): Remove attachment payloads from
failure report results
batch_size (int): Number of messages to read and process before saving
since: Search for messages since a certain time
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
config_reloading: Optional callable that returns True when a config
reload (or shutdown) has been requested (e.g. via SIGHUP/SIGTERM).
Polled by the mailbox backend between checks, including the IMAP
IDLE loop, so the watcher exits cleanly at a safe boundary.
n_procs (int): Number of processes to use for parsing messages in
parallel. Passed through to ``get_dmarc_reports_from_mailbox``
on each check. Not part of ``config``; always applies.
parse_timeout (float): Stop parsing a message after this many
seconds and treat it as invalid (``0``, the default, means no
limit). Passed through to ``get_dmarc_reports_from_mailbox``
on each check; a negative or non-finite value raises
``ValueError`` before the watch starts. Not part of ``config``;
always applies.
max_unsaved_retries (int): How many times a message may be retried
after ``callback`` first reported its batch unsaved, before it is
moved to the ``Unsaved`` archive subfolder (default 2). Passed
through to ``get_dmarc_reports_from_mailbox``, where it is
documented in full. Not part of ``config``; always applies.
attempts_file (str | None): A JSON file counting the parse
attempts that never finished, so a message that keeps killing
the process is set aside as invalid (default ``None``: no
record). Passed through to ``get_dmarc_reports_from_mailbox``,
where it is documented in full. Not part of ``config``; always
applies.
config (ParserConfig): a single object carrying all parsing and
enrichment options plus the caches; when provided, it replaces
the individual parsing and enrichment option keyword arguments
listed above (DNS, GeoIP, offline mode, attachment payload
stripping, timespan normalization), whose values are then
ignored. The remaining keyword arguments control mailbox
handling and orchestration rather than parsing (the folder
names, the ``delete`` options, ``test``, ``since``,
``batch_size``, ``max_unsaved_retries``, ``attempts_file``);
they are not part of ``config`` and always apply.
"""
# Validate before the watch loop starts: raised inside a check, this
# would be swallowed and endlessly retried by the IMAP and Maildir
# backends' per-check exception handling instead of surfacing.
if max_unsaved_retries < 0:
raise ValueError("max_unsaved_retries must be >= 0")
from parsedmarc.parallel import _check_parse_timeout
_check_parse_timeout(parse_timeout)
cfg = _resolve_config(
config,
offline=offline,
ip_db_path=ip_db_path,
always_use_local_files=always_use_local_files,
reverse_dns_map_path=reverse_dns_map_path,
reverse_dns_map_url=reverse_dns_map_url,
nameservers=nameservers,
dns_timeout=dns_timeout,
dns_retries=dns_retries,
strip_attachment_payloads=strip_attachment_payloads,
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
)
def check_callback(connection):
get_dmarc_reports_from_mailbox(
connection=connection,
reports_folder=reports_folder,
archive_folder=archive_folder,
delete=delete,
delete_aggregate=delete_aggregate,
delete_failure=delete_failure,
delete_smtp_tls=delete_smtp_tls,
delete_invalid=delete_invalid,
test=test,
batch_size=batch_size,
since=since,
create_folders=False,
n_procs=n_procs,
parse_timeout=parse_timeout,
save_callback=callback,
max_unsaved_retries=max_unsaved_retries,
attempts_file=attempts_file,
config=cfg,
)
watch_kwargs: dict = {
"check_callback": check_callback,
"check_timeout": check_timeout,
}
if config_reloading is not None:
watch_kwargs["config_reloading"] = config_reloading
mailbox_connection.watch(**watch_kwargs)
def append_json(
filename: str,
reports: Sequence[AggregateReport]
| Sequence[FailureReport]
| Sequence[SMTPTLSReport],
) -> None:
"""Append ``reports`` to a JSON array on disk, creating the file
if needed.
Reads the existing array (if the file exists and parses cleanly),
merges the new reports onto the end, and rewrites the file as a
single valid JSON array. An earlier version of this used an
``open(..., "a+")`` + ``seek()`` + overwrite pattern, but Python's
documentation is explicit that on POSIX, ``a`` / ``a+`` writes
*always* go to EOF regardless of seek position — so the second
call onto an existing file produced ``[...],\\n[...]``-style
corrupted output. Read-merge-write is the only way to get a valid
JSON array out of repeated appends.
"""
if len(reports) == 0:
# Don't create an empty-array file for an empty input; if a
# file already exists, leave it alone.
return
existing: list = []
if os.path.isfile(filename) and os.path.getsize(filename) > 0:
try:
with open(filename, "r", encoding="utf-8") as f:
loaded = json.loads(f.read())
if isinstance(loaded, list):
existing = loaded
except (json.JSONDecodeError, OSError):
# Corrupted or unreadable: overwrite cleanly rather than
# silently fail to record.
existing = []
merged = existing + list(reports)
with open(filename, "w", newline="\n", encoding="utf-8") as output:
json.dump(merged, output, ensure_ascii=False, indent=2)
def append_csv(filename: str, csv: str) -> None:
with open(filename, "a+", newline="\n", encoding="utf-8") as output:
if output.seek(0, os.SEEK_END) != 0:
# strip the headers from the CSV
_headers, csv = csv.split("\n", 1)
if len(csv) == 0:
# not appending anything, don't do any dance to
# append it correctly
return
output.write(csv)
def save_output(
results: ParsingResults,
*,
output_directory: str = "output",
aggregate_json_filename: str = "aggregate.json",
failure_json_filename: str = "failure.json",
smtp_tls_json_filename: str = "smtp_tls.json",
aggregate_csv_filename: str = "aggregate.csv",
failure_csv_filename: str = "failure.csv",
smtp_tls_csv_filename: str = "smtp_tls.csv",
):
"""
Save report data in the given directory
The message sample of each failure report is written to a ``samples``
subdirectory, named after the sample's own ``subject`` header run
through :func:`parsedmarc.utils.get_filename_safe_string`, falling back
to ``sample`` when sanitizing leaves nothing. Since a subject arrives
from an untrusted sender, sanitizing happens here, at write time; the
``filename_safe_subject`` key a caller may supply alongside it is not
trusted and not used. Names that collide get a ``(1)``, ``(2)``, …
suffix.
Args:
results: Parsing results
output_directory (str): The path to the directory to save in
aggregate_json_filename (str): Filename for the aggregate JSON file
failure_json_filename (str): Filename for the failure JSON file
smtp_tls_json_filename (str): Filename for the SMTP TLS JSON file
aggregate_csv_filename (str): Filename for the aggregate CSV file
failure_csv_filename (str): Filename for the failure CSV file
smtp_tls_csv_filename (str): Filename for the SMTP TLS CSV file
"""
aggregate_reports = results["aggregate_reports"]
failure_reports = results["failure_reports"]
smtp_tls_reports = results["smtp_tls_reports"]
output_directory = os.path.expanduser(output_directory)
if os.path.exists(output_directory):
if not os.path.isdir(output_directory):
raise ValueError(f"{output_directory} is not a directory")
else:
os.makedirs(output_directory)
append_json(
os.path.join(output_directory, aggregate_json_filename), aggregate_reports
)
append_csv(
os.path.join(output_directory, aggregate_csv_filename),
parsed_aggregate_reports_to_csv(aggregate_reports),
)
append_json(os.path.join(output_directory, failure_json_filename), failure_reports)
append_csv(
os.path.join(output_directory, failure_csv_filename),
parsed_failure_reports_to_csv(failure_reports),
)
append_json(
os.path.join(output_directory, smtp_tls_json_filename), smtp_tls_reports
)
append_csv(
os.path.join(output_directory, smtp_tls_csv_filename),
parsed_smtp_tls_reports_to_csv(smtp_tls_reports),
)
samples_directory = os.path.join(output_directory, "samples")
if not os.path.exists(samples_directory):
os.makedirs(samples_directory)
sample_filenames = []
for failure_report in failure_reports:
sample = failure_report["sample"]
message_count = 0
parsed_sample = failure_report["parsed_sample"]
subject = get_filename_safe_string(parsed_sample.get("subject")) or "sample"
filename = subject
while filename in sample_filenames:
message_count += 1
filename = f"{subject} ({message_count})"
sample_filenames.append(filename)
filename = f"{filename}.eml"
path = os.path.join(samples_directory, filename)
with open(path, "w", newline="\n", encoding="utf-8") as sample_file:
sample_file.write(sample)
def get_report_zip(results: ParsingResults) -> bytes:
"""
Creates a zip file of parsed report output
Args:
results: The parsed results
Returns:
bytes: zip file bytes
"""
def add_subdir(root_path, subdir):
subdir_path = os.path.join(root_path, subdir)
for subdir_root, subdir_dirs, subdir_files in os.walk(subdir_path):
for subdir_file in subdir_files:
subdir_file_path = os.path.join(root_path, subdir, subdir_file)
if os.path.isfile(subdir_file_path):
rel_path = os.path.relpath(subdir_root, subdir_file_path)
subdir_arc_name = os.path.join(rel_path, subdir_file)
zip_file.write(subdir_file_path, subdir_arc_name)
for subdir in subdir_dirs:
add_subdir(subdir_path, subdir)
storage = BytesIO()
tmp_dir = tempfile.mkdtemp()
try:
save_output(results, output_directory=tmp_dir)
with zipfile.ZipFile(storage, "w", zipfile.ZIP_DEFLATED) as zip_file:
for root, dirs, files in os.walk(tmp_dir):
for file in files:
file_path = os.path.join(root, file)
if os.path.isfile(file_path):
arcname = os.path.join(os.path.relpath(root, tmp_dir), file)
zip_file.write(file_path, arcname)
for directory in dirs:
dir_path = os.path.join(root, directory)
if os.path.isdir(dir_path):
zip_file.write(dir_path, directory)
add_subdir(root, directory)
finally:
shutil.rmtree(tmp_dir)
return storage.getvalue()
def _build_report_email_content(
results: ParsingResults,
*,
subject: str | None = None,
attachment_filename: str | None = None,
message: str | None = None,
) -> tuple[str, str, list[tuple[str, bytes]]]:
"""Builds the subject, plain-text body, and zip attachment shared by
every report-summary email transport.
Returns:
A ``(subject, plain_message, attachments)`` tuple.
"""
date_string = datetime.now().strftime("%Y-%m-%d")
if attachment_filename:
if not attachment_filename.lower().endswith(".zip"):
attachment_filename += ".zip"
filename = attachment_filename
else:
filename = f"DMARC-{date_string}.zip"
if subject is None:
subject = f"DMARC results for {date_string}"
if message is None:
message = f"DMARC results for {date_string}"
zip_bytes = get_report_zip(results)
attachments = [(filename, zip_bytes)]
return subject, message, attachments
def email_results(
results: ParsingResults,
host: str,
mail_from: str,
mail_to: list[str] | None,
*,
mail_cc: list[str] | None = None,
mail_bcc: list[str] | None = None,
port: int = 0,
require_encryption: bool = False,
verify: bool = True,
username: str | None = None,
password: str | None = None,
subject: str | None = None,
attachment_filename: str | None = None,
message: str | None = None,
starttls: bool | None = None,
):
"""
Emails parsing results as a zip file
Args:
results (dict): Parsing results
host (str): Mail server hostname or IP address
mail_from: The value of the message from header
mail_to (list): A list of addresses to mail to
mail_cc (list): A list of addresses to CC
mail_bcc (list): A list of addresses to BCC
port (int): Port to use
require_encryption (bool): Connect with implicit SSL/TLS from the
start (usually port 465). When ``False``, the connection starts
in plain text and ``starttls`` decides whether it is upgraded.
verify (bool): verify the SSL/TLS certificate
username (str): An optional username
password (str): An optional password
subject (str): Overrides the default message subject
attachment_filename (str): Override the default attachment filename
message (str): Override the default plain text body
starttls (bool | None): Whether to upgrade a plain-text
(``require_encryption=False``) connection with STARTTLS.
``None`` (the default) upgrades when the server advertises
STARTTLS; when it does not, ``mailsuite.smtp.SMTPError`` is
raised before authenticating if ``username`` and ``password``
are given, so credentials are never sent in plain text, and
without credentials the message is sent in plain text and a
warning is logged. ``True`` requires the upgrade and raises
``mailsuite.smtp.SMTPError`` if the server does not advertise
STARTTLS. ``False`` never upgrades, even when the server
advertises STARTTLS, so any credentials and the message are
sent in plain text (a warning is logged when credentials are
sent). Ignored when ``require_encryption=True``.
"""
logger.debug("Emailing report")
assert isinstance(mail_to, list)
subject, message, attachments = _build_report_email_content(
results,
subject=subject,
attachment_filename=attachment_filename,
message=message,
)
send_email(
host,
mail_from,
mail_to,
message_cc=mail_cc,
message_bcc=mail_bcc,
port=port,
require_encryption=require_encryption,
verify=verify,
username=username,
password=password,
subject=subject,
attachments=attachments,
plain_message=message,
starttls=starttls,
)
def email_results_via_msgraph(
results: ParsingResults,
connection: MSGraphConnection,
mail_to: list[str],
*,
mail_cc: list[str] | None = None,
mail_bcc: list[str] | None = None,
subject: str | None = None,
attachment_filename: str | None = None,
message: str | None = None,
) -> None:
"""
Emails parsing results as a zip file via an already-authenticated
Microsoft Graph mailbox connection (``/users/{mailbox}/sendMail``),
saving a copy to Sent Items.
Args:
results (dict): Parsing results
connection (MSGraphConnection): An already-authenticated Microsoft
Graph mailbox connection
mail_to (list): A list of addresses to mail to
mail_cc (list): A list of addresses to CC
mail_bcc (list): A list of addresses to BCC
subject (str): Overrides the default message subject
attachment_filename (str): Override the default attachment filename
message (str): Override the default plain text body
"""
logger.debug("Emailing report via Microsoft Graph")
subject, message, attachments = _build_report_email_content(
results,
subject=subject,
attachment_filename=attachment_filename,
message=message,
)
# Graph derives the From header from the authenticated mailbox and
# ignores message_from; it's still passed for API parity with
# send_message()'s signature.
connection.send_message(
message_from=connection.mailbox_name or "",
message_to=mail_to,
message_cc=mail_cc,
message_bcc=mail_bcc,
subject=subject,
attachments=attachments,
plain_message=message,
)
# Backward-compatible aliases
parse_forensic_report = parse_failure_report
parsed_forensic_reports_to_csv_rows = parsed_failure_reports_to_csv_rows
parsed_forensic_reports_to_csv = parsed_failure_reports_to_csv