aprf-reader 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neil Anuskiewicz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: aprf-reader
3
+ Version: 0.1.0
4
+ Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # aprf-reader
14
+
15
+ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
16
+ mail you signed landed: how much reached the inbox, how much was treated as
17
+ unwanted, and how recipients reacted, day by day and per DKIM selector.
18
+
19
+ **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
20
+
21
+ ## What APRF is
22
+
23
+ APRF is a proposed standard for a daily report from a mailbox provider about the
24
+ mail you signed with DKIM. It is an IETF working group draft,
25
+ [draft-ietf-mailmaint-aprf](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-aprf/),
26
+ adopted by the Mail Maintenance (MAILMAINT) working group in October 2026. Reports
27
+ arrive by email as small JSON files, one per day per signing domain and selector.
28
+ Comcast sends them today in a beta for comcast.net mail.
29
+
30
+ A plain-language introduction: [What APRF inbox placement reports tell you, and
31
+ what they don't](https://dns-audit.com/articles/aprf).
32
+
33
+ ## Use it
34
+
35
+ Python 3.11 or later, no other dependencies. Install with
36
+ [pipx](https://pipx.pypa.io/), which keeps command line tools in their own
37
+ environment, or with pip (`pip3` on some systems) inside a virtual environment.
38
+
39
+ ```bash
40
+ pipx install aprf-reader
41
+ aprf-reader reports/ # Markdown summary to the terminal
42
+ aprf-reader reports/ --html summary.html # and a page you can open or share
43
+ ```
44
+
45
+ Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
46
+ or folders of them. Folders are searched recursively. Files it skips are listed
47
+ on stderr and at the end of the summary, each with a reason.
48
+
49
+ Each stream (provider, signing domain, selector) gets a table with one row per
50
+ day and segment, a trend line per count, and a row for each day with no report.
51
+ A change in the report's `version` field is called out, since the draft uses it
52
+ to mark a change in what a category counts.
53
+
54
+ ## What it does
55
+
56
+ - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
57
+ duplicates and files it cannot read, with a reason for each.
58
+ - Summarise placement per signing domain and selector: inbox, unwanted and
59
+ recipient reactions, by day and as a trend.
60
+ - Treat the counts as what the draft says they are: rounded into buckets, so
61
+ they are a signal, not exact rates.
62
+ - Write a Markdown summary and a single self-contained HTML page you can open
63
+ or share. Nothing is uploaded anywhere.
64
+ - Follow the draft as it changes. The report format lives in one module
65
+ (`src/aprf_reader/report.py`), so a new draft revision is a small update.
66
+ - When the same day arrives twice, keep the first copy and say so. The draft
67
+ has not yet decided whether a resend replaces the original.
68
+
69
+ ## Related
70
+
71
+ - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
72
+ where its reports would go.
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,64 @@
1
+ # aprf-reader
2
+
3
+ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
4
+ mail you signed landed: how much reached the inbox, how much was treated as
5
+ unwanted, and how recipients reacted, day by day and per DKIM selector.
6
+
7
+ **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
8
+
9
+ ## What APRF is
10
+
11
+ APRF is a proposed standard for a daily report from a mailbox provider about the
12
+ mail you signed with DKIM. It is an IETF working group draft,
13
+ [draft-ietf-mailmaint-aprf](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-aprf/),
14
+ adopted by the Mail Maintenance (MAILMAINT) working group in October 2026. Reports
15
+ arrive by email as small JSON files, one per day per signing domain and selector.
16
+ Comcast sends them today in a beta for comcast.net mail.
17
+
18
+ A plain-language introduction: [What APRF inbox placement reports tell you, and
19
+ what they don't](https://dns-audit.com/articles/aprf).
20
+
21
+ ## Use it
22
+
23
+ Python 3.11 or later, no other dependencies. Install with
24
+ [pipx](https://pipx.pypa.io/), which keeps command line tools in their own
25
+ environment, or with pip (`pip3` on some systems) inside a virtual environment.
26
+
27
+ ```bash
28
+ pipx install aprf-reader
29
+ aprf-reader reports/ # Markdown summary to the terminal
30
+ aprf-reader reports/ --html summary.html # and a page you can open or share
31
+ ```
32
+
33
+ Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
34
+ or folders of them. Folders are searched recursively. Files it skips are listed
35
+ on stderr and at the end of the summary, each with a reason.
36
+
37
+ Each stream (provider, signing domain, selector) gets a table with one row per
38
+ day and segment, a trend line per count, and a row for each day with no report.
39
+ A change in the report's `version` field is called out, since the draft uses it
40
+ to mark a change in what a category counts.
41
+
42
+ ## What it does
43
+
44
+ - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
45
+ duplicates and files it cannot read, with a reason for each.
46
+ - Summarise placement per signing domain and selector: inbox, unwanted and
47
+ recipient reactions, by day and as a trend.
48
+ - Treat the counts as what the draft says they are: rounded into buckets, so
49
+ they are a signal, not exact rates.
50
+ - Write a Markdown summary and a single self-contained HTML page you can open
51
+ or share. Nothing is uploaded anywhere.
52
+ - Follow the draft as it changes. The report format lives in one module
53
+ (`src/aprf_reader/report.py`), so a new draft revision is a small update.
54
+ - When the same day arrives twice, keep the first copy and say so. The draft
55
+ has not yet decided whether a resend replaces the original.
56
+
57
+ ## Related
58
+
59
+ - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
60
+ where its reports would go.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aprf-reader"
7
+ version = "0.1.0"
8
+ description = "Summarise APRF (Aggregate Performance Reporting) inbox placement reports."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=7"]
16
+
17
+ [project.scripts]
18
+ aprf-reader = "aprf_reader.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Read APRF aggregate performance reports."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """Command line entry point: aprf-reader <paths...>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .load import load
11
+ from .render import to_html, to_markdown
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
16
+ p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml) or folders")
17
+ p.add_argument("--md", type=Path, help="write the Markdown summary here instead of printing it")
18
+ p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
19
+ p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
20
+ args = p.parse_args(argv)
21
+
22
+ for path in args.paths:
23
+ if not path.exists():
24
+ p.error(f"no such file or folder: {path}")
25
+ loaded = load(args.paths)
26
+ for where, reason in loaded.skipped:
27
+ print(f"skipped {where}: {reason}", file=sys.stderr)
28
+ if not loaded.reports:
29
+ print("no APRF reports found", file=sys.stderr)
30
+ return 1
31
+
32
+ md = to_markdown(loaded)
33
+ if args.md:
34
+ args.md.write_text(md, encoding="utf-8")
35
+ else:
36
+ sys.stdout.write(md)
37
+ if args.html:
38
+ args.html.write_text(to_html(loaded), encoding="utf-8")
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sys.exit(main())
@@ -0,0 +1,98 @@
1
+ """Find report files, open them, and drop duplicates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import io
8
+ import zlib
9
+ from collections.abc import Iterable, Iterator
10
+ from dataclasses import dataclass, field
11
+ from email import policy
12
+ from email.parser import BytesParser
13
+ from pathlib import Path
14
+
15
+ from .report import Report, ReportError, parse
16
+
17
+ SUFFIXES = (".json", ".json.gz", ".eml")
18
+ # A daily report is a few KB; this stops a stray huge file or gzip bomb
19
+ MAX_BYTES = 10 * 1024 * 1024
20
+
21
+
22
+ @dataclass
23
+ class Loaded:
24
+ reports: list[Report] = field(default_factory=list)
25
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
26
+
27
+
28
+ def _files(paths: Iterable[Path]) -> Iterator[Path]:
29
+ for p in paths:
30
+ if p.is_dir():
31
+ yield from sorted(f for f in p.rglob("*") if f.is_file() and f.name.lower().endswith(SUFFIXES))
32
+ else:
33
+ yield p
34
+
35
+
36
+ def _gunzip(data: bytes) -> bytes:
37
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
38
+ out = g.read(MAX_BYTES + 1)
39
+ if len(out) > MAX_BYTES:
40
+ raise ReportError("larger than 10 MB unpacked")
41
+ return out
42
+
43
+
44
+ def _payloads(path: Path) -> Iterator[tuple[str, bytes]]:
45
+ """Yield (label, report JSON bytes) for each report in a file."""
46
+ data = path.read_bytes()
47
+ if len(data) > MAX_BYTES:
48
+ raise ReportError("larger than 10 MB")
49
+ name = path.name.lower()
50
+ if name.endswith(".eml"):
51
+ msg = BytesParser(policy=policy.default).parsebytes(data)
52
+ found = False
53
+ for part in msg.walk():
54
+ fname = (part.get_filename() or "").lower()
55
+ ctype = part.get_content_type()
56
+ if fname.endswith(".json.gz") or ctype == "application/gzip":
57
+ found = True
58
+ yield f"{path} ({fname or ctype})", _gunzip(part.get_payload(decode=True) or b"")
59
+ elif fname.endswith(".json") or ctype == "application/json":
60
+ found = True
61
+ yield f"{path} ({fname or ctype})", part.get_payload(decode=True) or b""
62
+ if not found:
63
+ raise ReportError("no .json or .json.gz attachment")
64
+ elif data[:2] == b"\x1f\x8b":
65
+ yield str(path), _gunzip(data)
66
+ else:
67
+ yield str(path), data
68
+
69
+
70
+ def load(paths: Iterable[Path]) -> Loaded:
71
+ out = Loaded()
72
+ seen_bytes: set[str] = set()
73
+ seen_keys: dict[tuple, str] = {}
74
+ for path in _files(paths):
75
+ try:
76
+ payloads = list(_payloads(path))
77
+ except (OSError, EOFError, zlib.error, ReportError) as e:
78
+ out.skipped.append((str(path), str(e)))
79
+ continue
80
+ for where, data in payloads:
81
+ digest = hashlib.sha256(data).hexdigest()
82
+ if digest in seen_bytes:
83
+ out.skipped.append((where, "duplicate of a file already read"))
84
+ continue
85
+ seen_bytes.add(digest)
86
+ try:
87
+ reports = parse(data)
88
+ except ReportError as e:
89
+ out.skipped.append((where, str(e)))
90
+ continue
91
+ for r in reports:
92
+ # The draft does not yet say whether a resend replaces the first; keep the first
93
+ if r.key in seen_keys:
94
+ out.skipped.append((where, f"same day already reported in {seen_keys[r.key]}; kept that one"))
95
+ continue
96
+ seen_keys[r.key] = where
97
+ out.reports.append(r)
98
+ return out
@@ -0,0 +1,126 @@
1
+ """Write a stream summary as Markdown or as a self-contained HTML page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+
7
+ from . import __version__
8
+ from .load import Loaded
9
+ from .report import DRAFT
10
+ from .summary import Row, Stream, streams
11
+
12
+ CAVEAT = (
13
+ "Counts are bucket upper bounds chosen by each provider: a ceiling, not an exact figure. "
14
+ "Placement counts mail received that day; engagement counts actions taken that day on mail "
15
+ "from any day. Dividing one count by another gives a ratio nobody measured."
16
+ )
17
+
18
+
19
+ def _n(v: int | None) -> str:
20
+ return "" if v is None else f"{v:,}"
21
+
22
+
23
+ def _md(text: str) -> str:
24
+ return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
25
+
26
+
27
+ def _plural(n: int, word: str) -> str:
28
+ return f"{n} {word}{'' if n == 1 else 's'}"
29
+
30
+
31
+ def _counts(loaded: Loaded, ss: list[Stream]) -> str:
32
+ return f"{_plural(len(loaded.reports), 'report')} in {_plural(len(ss), 'stream')}."
33
+
34
+
35
+ def _span(s: Stream) -> str:
36
+ gap = f", {_plural(s.missing, 'day')} with no report" if s.missing else ""
37
+ return f"{s.first} to {s.last}: {_plural(len(s.by_day), 'report')}{gap}"
38
+
39
+
40
+ def _day(row: Row) -> str:
41
+ return str(row.day) if row.until == row.day else f"{row.day} to {row.until}"
42
+
43
+
44
+ def to_markdown(loaded: Loaded) -> str:
45
+ ss = streams(loaded.reports)
46
+ lines = ["# APRF placement summary", "", f"{_counts(loaded, ss)} {CAVEAT}", ""]
47
+ for s in ss:
48
+ lines += [f"## {_md(s.source)}: {_md(s.domain)}, selector {_md(s.selector)}", "", _span(s), ""]
49
+ cols = [k for _, k in s.columns]
50
+ lines.append("| Day | Segment | " + " | ".join(_md(c) for c in cols) + " |")
51
+ lines.append("|---|---|" + "---:|" * len(cols))
52
+ for row in s.rows:
53
+ if row.segment is None:
54
+ lines.append(f"| {_day(row)} | no report |" + " |" * len(cols))
55
+ else:
56
+ lines.append(f"| {row.day} | {_md(row.segment)} | " + " | ".join(_n(v) for v in row.values) + " |")
57
+ lines.append("")
58
+ if s.notes:
59
+ lines += [f"- {_md(n)}" for n in s.notes] + [""]
60
+ if loaded.skipped:
61
+ lines += ["## Skipped", ""] + [f"- {_md(w)}: {_md(r)}" for w, r in loaded.skipped] + [""]
62
+ lines.append(f"Read by aprf-reader {__version__} against {DRAFT}.")
63
+ return "\n".join(lines) + "\n"
64
+
65
+
66
+ def _spark(points: list[tuple[int, int]]) -> str:
67
+ """Line over (day offset, value) points, broken where days are missing."""
68
+ if len(points) < 2:
69
+ return ""
70
+ w, h = 96, 20
71
+ top = max(v for _, v in points) or 1
72
+ step = w / points[-1][0]
73
+ path, prev = [], None
74
+ for x, v in points:
75
+ cmd = "L" if prev is not None and x - prev == 1 else "M"
76
+ path.append(f"{cmd}{x * step:.1f},{h - 1 - (v / top) * (h - 2):.1f}")
77
+ prev = x
78
+ return (f'<svg class="spark" viewBox="-1 0 {w + 2} {h}" width="{w}" height="{h}" aria-hidden="true">'
79
+ f'<path d="{" ".join(path)}" fill="none" stroke="currentColor" stroke-width="1.5" '
80
+ 'stroke-linecap="round"/></svg>')
81
+
82
+
83
+ CSS = """
84
+ :root{--bg:#fff;--fg:#1a1a1a;--muted:#5f6368;--line:#dadce0;--gap:#f6f6f6;--accent:#1a5fb4}
85
+ @media (prefers-color-scheme:dark){:root{--bg:#161616;--fg:#e8e8e8;--muted:#a0a0a0;--line:#3a3a3a;--gap:#202020;--accent:#78aeed}}
86
+ body{background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif;margin:0 auto;max-width:960px;padding:24px 16px}
87
+ h1{font-size:1.5rem}h2{font-size:1.15rem;margin-top:2rem}
88
+ .muted{color:var(--muted)}
89
+ .wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
90
+ th,td{border-bottom:1px solid var(--line);padding:4px 8px;text-align:right;white-space:nowrap}
91
+ th:nth-child(-n+2),td:nth-child(-n+2){text-align:left}
92
+ tr.gap td{background:var(--gap);color:var(--muted)}tr.trend td{border-bottom:2px solid var(--line)}
93
+ .spark{color:var(--accent);vertical-align:middle}
94
+ """
95
+
96
+
97
+ def to_html(loaded: Loaded) -> str:
98
+ e = html.escape
99
+ ss = streams(loaded.reports)
100
+ parts = [
101
+ "<!doctype html><html lang=en><head><meta charset=utf-8>",
102
+ '<meta name=viewport content="width=device-width,initial-scale=1">',
103
+ f"<title>APRF placement summary</title><style>{CSS}</style></head><body>",
104
+ "<h1>APRF placement summary</h1>",
105
+ f"<p>{_counts(loaded, ss)}</p><p class=muted>{e(CAVEAT)}</p>",
106
+ ]
107
+ for s in ss:
108
+ cols = [k for _, k in s.columns]
109
+ parts.append(f"<h2>{e(s.source)}: {e(s.domain)}, selector {e(s.selector)}</h2>")
110
+ parts.append(f"<p class=muted>{e(_span(s))}</p><div class=wrap><table><thead><tr><th>Day<th>Segment")
111
+ parts.append("".join(f"<th>{e(c)}" for c in cols) + "</thead><tbody>")
112
+ parts.append("<tr class=trend><td>Trend<td class=muted>sum per day")
113
+ parts.append("".join(f"<td>{_spark(s.trend(i))}" for i in range(len(cols))))
114
+ for row in s.rows:
115
+ if row.segment is None:
116
+ parts.append(f"<tr class=gap><td>{_day(row)}<td>no report" + "<td>" * len(cols))
117
+ else:
118
+ parts.append(f"<tr><td>{row.day}<td>{e(row.segment)}" + "".join(f"<td>{_n(v)}" for v in row.values))
119
+ parts.append("</tbody></table></div>")
120
+ if s.notes:
121
+ parts.append("<ul>" + "".join(f"<li>{e(n)}" for n in s.notes) + "</ul>")
122
+ if loaded.skipped:
123
+ parts.append("<h2>Skipped</h2><ul>" + "".join(f"<li>{e(w)}: {e(r)}" for w, r in loaded.skipped) + "</ul>")
124
+ parts.append(f"<p class=muted>Read by aprf-reader {e(__version__)} against {DRAFT}. Nothing left this computer.</p>")
125
+ parts.append("</body></html>")
126
+ return "\n".join(parts) + "\n"
@@ -0,0 +1,141 @@
1
+ """The APRF report format, per draft-ietf-mailmaint-aprf-00.
2
+
3
+ Everything that knows field names lives here, so a new draft revision is an
4
+ edit to this file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, date, datetime
12
+
13
+ DRAFT = "draft-ietf-mailmaint-aprf-00"
14
+
15
+ # Names the draft defines, in display order; providers may add others
16
+ PLACEMENT_NAMES = ("inbox", "unwanted")
17
+ ENGAGEMENT_NAMES = ("positive", "neutral", "negative")
18
+ # sdi_used values that mean no SDI was applied (section 5.2.1)
19
+ NO_SDI = ("", "N/A", "N/F")
20
+
21
+
22
+ class ReportError(ValueError):
23
+ """The data is not an APRF report this module can read."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Segment:
28
+ # () when the report is flat (no SDI); otherwise up to four values, broad to narrow
29
+ path: tuple[str, ...]
30
+ # Provider-defined names (inbox, unwanted, ...) to bucket upper bounds
31
+ classification: dict[str, int] = field(default_factory=dict)
32
+ engagement: dict[str, int] = field(default_factory=dict)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Report:
37
+ source: str
38
+ dkim_domain: str
39
+ dkim_selector: str
40
+ report_start: int
41
+ report_end: int
42
+ version: str
43
+ sdi_used: str
44
+ contact_info: str
45
+ extra_info: str
46
+ segments: tuple[Segment, ...]
47
+
48
+ @property
49
+ def day(self) -> date:
50
+ # Section 8: the report's date is its ending date, in UTC
51
+ return _day(self.report_end)
52
+
53
+ @property
54
+ def key(self) -> tuple[str, str, str, date]:
55
+ return (self.source, self.dkim_domain.lower(), self.dkim_selector, self.day)
56
+
57
+
58
+ def _day(epoch: int) -> date:
59
+ return datetime.fromtimestamp(epoch, UTC).date()
60
+
61
+
62
+ def _counts(value: object, where: str) -> dict[str, int]:
63
+ if value is None:
64
+ return {}
65
+ if not isinstance(value, dict):
66
+ raise ReportError(f"{where} is not an object")
67
+ out = {}
68
+ for name, n in value.items():
69
+ if isinstance(n, bool) or not isinstance(n, int) or n < 0:
70
+ raise ReportError(f"{where}.{name} is not a count: {n!r}")
71
+ out[str(name)] = n
72
+ return out
73
+
74
+
75
+ def _segment(item: object, i: int) -> Segment:
76
+ if not isinstance(item, dict):
77
+ raise ReportError(f"body[{i}] is not an object")
78
+ seg = item.get("segment")
79
+ # Section 5.2.2 shows a string, the samples a list
80
+ if seg is None:
81
+ path: tuple[str, ...] = ()
82
+ elif isinstance(seg, str):
83
+ path = (seg,)
84
+ elif isinstance(seg, list) and all(isinstance(s, str) for s in seg):
85
+ path = tuple(seg)
86
+ else:
87
+ raise ReportError(f"body[{i}].segment is not a string or list of strings")
88
+ return Segment(
89
+ path,
90
+ _counts(item.get("classification"), f"body[{i}].classification"),
91
+ _counts(item.get("engagement"), f"body[{i}].engagement"),
92
+ )
93
+
94
+
95
+ def _text(value: object) -> str:
96
+ return "" if value is None else str(value)
97
+
98
+
99
+ def _report(obj: object) -> Report:
100
+ if not isinstance(obj, dict) or not isinstance(obj.get("header"), dict):
101
+ raise ReportError("no header object")
102
+ h = obj["header"]
103
+ for name in ("dkim_domain", "dkim_selector"):
104
+ if not isinstance(h.get(name), str) or not h[name]:
105
+ raise ReportError(f"header.{name} is missing")
106
+ for name in ("report_start", "report_end"):
107
+ if isinstance(h.get(name), bool) or not isinstance(h.get(name), int):
108
+ raise ReportError(f"header.{name} is not an epoch time")
109
+ try:
110
+ _day(h["report_end"])
111
+ except (OverflowError, OSError, ValueError):
112
+ raise ReportError(f"header.report_end is out of range: {h['report_end']}") from None
113
+ body = obj.get("body", [])
114
+ if not isinstance(body, list):
115
+ raise ReportError("body is not a list")
116
+ return Report(
117
+ source=_text(h.get("source")) or "unknown",
118
+ dkim_domain=h["dkim_domain"],
119
+ dkim_selector=h["dkim_selector"],
120
+ report_start=h["report_start"],
121
+ report_end=h["report_end"],
122
+ version=_text(h.get("version")),
123
+ sdi_used=_text(h.get("sdi_used")),
124
+ contact_info=_text(h.get("contact_info")),
125
+ extra_info=_text(h.get("extra_info")),
126
+ segments=tuple(_segment(s, i) for i, s in enumerate(body)),
127
+ )
128
+
129
+
130
+ def parse(data: bytes) -> list[Report]:
131
+ """Parse one report document. The draft's samples wrap reports in a list."""
132
+ try:
133
+ doc = json.loads(data)
134
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
135
+ raise ReportError(f"not JSON: {e}") from None
136
+ except RecursionError:
137
+ raise ReportError("JSON nested too deeply") from None
138
+ items = doc if isinstance(doc, list) else [doc]
139
+ if not items:
140
+ raise ReportError("empty list")
141
+ return [_report(o) for o in items]
@@ -0,0 +1,93 @@
1
+ """Group reports into streams: one provider, signing domain and selector over time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from datetime import date, timedelta
8
+ from functools import cached_property
9
+
10
+ from .report import ENGAGEMENT_NAMES, NO_SDI, PLACEMENT_NAMES, Report
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Row:
15
+ day: date
16
+ # For a gap, the last missing day of the run; equal to day otherwise
17
+ until: date
18
+ # None for a run of days with no report
19
+ segment: str | None
20
+ values: tuple[int | None, ...] = ()
21
+
22
+
23
+ @dataclass
24
+ class Stream:
25
+ source: str
26
+ domain: str
27
+ selector: str
28
+ by_day: dict[date, Report]
29
+
30
+ @cached_property
31
+ def first(self) -> date:
32
+ return min(self.by_day)
33
+
34
+ @cached_property
35
+ def last(self) -> date:
36
+ return max(self.by_day)
37
+
38
+ @cached_property
39
+ def missing(self) -> int:
40
+ return (self.last - self.first).days + 1 - len(self.by_day)
41
+
42
+ def _names(self, attr: str, order: tuple[str, ...]) -> list[str]:
43
+ seen = {k for r in self.by_day.values() for s in r.segments for k in getattr(s, attr)}
44
+ return [k for k in order if k in seen] + sorted(seen - set(order))
45
+
46
+ @cached_property
47
+ def columns(self) -> list[tuple[str, str]]:
48
+ """(attribute, name) pairs: placement counts, then engagement counts."""
49
+ return ([("classification", k) for k in self._names("classification", PLACEMENT_NAMES)]
50
+ + [("engagement", k) for k in self._names("engagement", ENGAGEMENT_NAMES)])
51
+
52
+ @cached_property
53
+ def rows(self) -> list[Row]:
54
+ """One row per segment per report day; one row per run of days with no report."""
55
+ out: list[Row] = []
56
+ prev: date | None = None
57
+ for d in sorted(self.by_day):
58
+ if prev is not None and (d - prev).days > 1:
59
+ out.append(Row(prev + timedelta(1), d - timedelta(1), None))
60
+ for s in self.by_day[d].segments:
61
+ vals = tuple(getattr(s, a).get(k) for a, k in self.columns)
62
+ out.append(Row(d, d, " / ".join(s.path) or "all mail", vals))
63
+ prev = d
64
+ return out
65
+
66
+ def trend(self, col: int) -> list[tuple[int, int]]:
67
+ """(day offset from first, sum across segments) for each report day."""
68
+ a, k = self.columns[col]
69
+ return [((d - self.first).days, sum(getattr(s, a).get(k, 0) for s in r.segments))
70
+ for d, r in sorted(self.by_day.items())]
71
+
72
+ @cached_property
73
+ def notes(self) -> list[str]:
74
+ out = []
75
+ prev: str | None = None
76
+ for d, r in sorted(self.by_day.items()):
77
+ if prev is not None and r.version != prev:
78
+ out.append(f"Report version changed from {prev} to {r.version} on {d}: "
79
+ "the provider may have changed what a category counts.")
80
+ prev = r.version
81
+ # "TBD" is the draft samples' placeholder
82
+ out += [f"Provider note: {t}" for t in sorted({r.extra_info for r in self.by_day.values()} - {"", "TBD"})]
83
+ sdi = sorted({r.sdi_used for r in self.by_day.values()} - set(NO_SDI))
84
+ if sdi:
85
+ out.append("SDI used: " + ", ".join(sdi))
86
+ return out
87
+
88
+
89
+ def streams(reports: list[Report]) -> list[Stream]:
90
+ groups: dict[tuple[str, str, str], dict[date, Report]] = defaultdict(dict)
91
+ for r in reports:
92
+ groups[(r.source, r.dkim_domain.lower(), r.dkim_selector)][r.day] = r
93
+ return [Stream(src, dom, sel, days) for (src, dom, sel), days in sorted(groups.items())]
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: aprf-reader
3
+ Version: 0.1.0
4
+ Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # aprf-reader
14
+
15
+ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
16
+ mail you signed landed: how much reached the inbox, how much was treated as
17
+ unwanted, and how recipients reacted, day by day and per DKIM selector.
18
+
19
+ **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
20
+
21
+ ## What APRF is
22
+
23
+ APRF is a proposed standard for a daily report from a mailbox provider about the
24
+ mail you signed with DKIM. It is an IETF working group draft,
25
+ [draft-ietf-mailmaint-aprf](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-aprf/),
26
+ adopted by the Mail Maintenance (MAILMAINT) working group in October 2026. Reports
27
+ arrive by email as small JSON files, one per day per signing domain and selector.
28
+ Comcast sends them today in a beta for comcast.net mail.
29
+
30
+ A plain-language introduction: [What APRF inbox placement reports tell you, and
31
+ what they don't](https://dns-audit.com/articles/aprf).
32
+
33
+ ## Use it
34
+
35
+ Python 3.11 or later, no other dependencies. Install with
36
+ [pipx](https://pipx.pypa.io/), which keeps command line tools in their own
37
+ environment, or with pip (`pip3` on some systems) inside a virtual environment.
38
+
39
+ ```bash
40
+ pipx install aprf-reader
41
+ aprf-reader reports/ # Markdown summary to the terminal
42
+ aprf-reader reports/ --html summary.html # and a page you can open or share
43
+ ```
44
+
45
+ Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
46
+ or folders of them. Folders are searched recursively. Files it skips are listed
47
+ on stderr and at the end of the summary, each with a reason.
48
+
49
+ Each stream (provider, signing domain, selector) gets a table with one row per
50
+ day and segment, a trend line per count, and a row for each day with no report.
51
+ A change in the report's `version` field is called out, since the draft uses it
52
+ to mark a change in what a category counts.
53
+
54
+ ## What it does
55
+
56
+ - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
57
+ duplicates and files it cannot read, with a reason for each.
58
+ - Summarise placement per signing domain and selector: inbox, unwanted and
59
+ recipient reactions, by day and as a trend.
60
+ - Treat the counts as what the draft says they are: rounded into buckets, so
61
+ they are a signal, not exact rates.
62
+ - Write a Markdown summary and a single self-contained HTML page you can open
63
+ or share. Nothing is uploaded anywhere.
64
+ - Follow the draft as it changes. The report format lives in one module
65
+ (`src/aprf_reader/report.py`), so a new draft revision is a small update.
66
+ - When the same day arrives twice, keep the first copy and say so. The draft
67
+ has not yet decided whether a resend replaces the original.
68
+
69
+ ## Related
70
+
71
+ - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
72
+ where its reports would go.
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/aprf_reader/__init__.py
5
+ src/aprf_reader/cli.py
6
+ src/aprf_reader/load.py
7
+ src/aprf_reader/render.py
8
+ src/aprf_reader/report.py
9
+ src/aprf_reader/summary.py
10
+ src/aprf_reader.egg-info/PKG-INFO
11
+ src/aprf_reader.egg-info/SOURCES.txt
12
+ src/aprf_reader.egg-info/dependency_links.txt
13
+ src/aprf_reader.egg-info/entry_points.txt
14
+ src/aprf_reader.egg-info/requires.txt
15
+ src/aprf_reader.egg-info/top_level.txt
16
+ tests/test_reader.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aprf-reader = aprf_reader.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7
@@ -0,0 +1 @@
1
+ aprf_reader
@@ -0,0 +1,139 @@
1
+ import gzip
2
+ import json
3
+ from datetime import date
4
+ from email.message import EmailMessage
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from aprf_reader.cli import main
10
+ from aprf_reader.load import load
11
+ from aprf_reader.render import to_html, to_markdown
12
+ from aprf_reader.summary import streams
13
+ from aprf_reader.report import ReportError, parse
14
+
15
+ FIX = Path(__file__).parent / "fixtures"
16
+ DAY = 86400
17
+
18
+
19
+ def report(day: int, inbox: int = 100, source: str = "Comcast", **header) -> dict:
20
+ start = 1790812800 + day * DAY # 2026-10-01 00:00 UTC
21
+ h = {"version": "1", "source": source, "dkim_domain": "example.com", "dkim_selector": "s1",
22
+ "report_start": start, "report_end": start + DAY - 1, "sdi_used": "N/F", **header}
23
+ return {"header": h, "body": [{"classification": {"inbox": inbox, "unwanted": 10},
24
+ "engagement": {"positive": 5, "negative": 1}}]}
25
+
26
+
27
+ def write(path: Path, obj) -> Path:
28
+ path.write_text(json.dumps(obj))
29
+ return path
30
+
31
+
32
+ def test_draft_samples_parse():
33
+ (r1,) = parse((FIX / "draft_sample_1.json").read_bytes())
34
+ assert r1.dkim_selector == "selector1" and r1.version == "42"
35
+ assert [s.path for s in r1.segments] == [("Seg1", "Seg2", "Seg3"), ("Seg1", "Seg2", "Other"), ("Seg1", "Other")]
36
+ assert r1.segments[0].classification == {"inbox": 10000, "unwanted": 500}
37
+ assert r1.day == date(2024, 2, 29)
38
+ (r2,) = parse((FIX / "draft_sample_2.json").read_bytes())
39
+ assert r2.segments[0].path == () and r2.sdi_used == "N/F"
40
+
41
+
42
+ def test_bare_object_and_string_segment():
43
+ obj = report(0)
44
+ obj["body"][0]["segment"] = "CustomerA"
45
+ (r,) = parse(json.dumps(obj).encode())
46
+ assert r.segments[0].path == ("CustomerA",)
47
+
48
+
49
+ @pytest.mark.parametrize("mutate, reason", [
50
+ (lambda o: o.pop("header"), "no header"),
51
+ (lambda o: o["header"].pop("dkim_domain"), "dkim_domain"),
52
+ (lambda o: o["header"].update(report_end="yesterday"), "report_end"),
53
+ (lambda o: o["body"][0]["classification"].update(inbox=-1), "not a count"),
54
+ (lambda o: o["body"][0]["engagement"].update(positive=True), "not a count"),
55
+ ])
56
+ def test_bad_reports_rejected(mutate, reason):
57
+ obj = report(0)
58
+ mutate(obj)
59
+ with pytest.raises(ReportError, match=reason):
60
+ parse(json.dumps(obj).encode())
61
+
62
+
63
+ def test_load_gz_eml_folder_and_skips(tmp_path):
64
+ write(tmp_path / "a.json", report(0))
65
+ (tmp_path / "b.json.gz").write_bytes(gzip.compress(json.dumps(report(1)).encode()))
66
+ msg = EmailMessage()
67
+ msg["Subject"] = "APRF: 20261003_example.com_s1_Comcast"
68
+ msg.set_content("report attached")
69
+ msg.add_attachment(gzip.compress(json.dumps(report(2)).encode()), maintype="application",
70
+ subtype="gzip", filename="20261003_example.com_s1_Comcast.json.gz")
71
+ (tmp_path / "c.eml").write_bytes(msg.as_bytes())
72
+ write(tmp_path / "copy.json", report(0)) # same bytes as a.json
73
+ write(tmp_path / "resend.json", report(0, inbox=999)) # same day, different counts
74
+ (tmp_path / "broken.json").write_text("{not json")
75
+ (tmp_path / "notes.txt").write_text("ignored: not a report suffix")
76
+
77
+ loaded = load([tmp_path])
78
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3]
79
+ reasons = dict((Path(w.split(" ")[0]).name, r) for w, r in loaded.skipped)
80
+ assert set(reasons) == {"copy.json", "resend.json", "broken.json"}
81
+ assert "duplicate" in reasons["copy.json"] and "same day" in reasons["resend.json"]
82
+
83
+
84
+ def test_hostile_files_skipped_not_crashed(tmp_path):
85
+ (tmp_path / "deep.json").write_text("[" * 100_000 + "]" * 100_000)
86
+ write(tmp_path / "far.json", report(0, report_end=10**20))
87
+ good = gzip.compress(json.dumps(report(0)).encode())
88
+ (tmp_path / "bad-deflate.json.gz").write_bytes(good[:10] + b"\x07" * (len(good) - 10))
89
+ (tmp_path / "truncated.json.gz").write_bytes(good[:-12])
90
+ loaded = load([tmp_path])
91
+ assert not loaded.reports and len(loaded.skipped) == 4
92
+ reasons = " ".join(r for _, r in loaded.skipped)
93
+ assert "nested too deeply" in reasons and "out of range" in reasons
94
+
95
+
96
+ def test_gzip_bomb_skipped(tmp_path):
97
+ (tmp_path / "big.json.gz").write_bytes(gzip.compress(b" " * (11 * 1024 * 1024)))
98
+ loaded = load([tmp_path])
99
+ assert not loaded.reports and "10 MB" in loaded.skipped[0][1]
100
+
101
+
102
+ def test_stream_gaps_versions_and_columns(tmp_path):
103
+ write(tmp_path / "1.json", report(0))
104
+ write(tmp_path / "2.json", report(3, version="2", extra_info="positive now includes replies"))
105
+ write(tmp_path / "3.json", report(1, source="Other MBP"))
106
+ ss = streams(load([tmp_path]).reports)
107
+ assert [s.source for s in ss] == ["Comcast", "Other MBP"]
108
+ s = ss[0]
109
+ assert s.missing == 2
110
+ assert [k for _, k in s.columns] == ["inbox", "unwanted", "positive", "negative"]
111
+ gaps = [(r.day, r.until) for r in s.rows if r.segment is None]
112
+ assert gaps == [(date(2026, 10, 2), date(2026, 10, 3))] # one row per run, not per day
113
+ assert [x for x, _ in s.trend(0)] == [0, 3]
114
+ notes = " ".join(s.notes)
115
+ assert "SDI" not in notes # N/F means none
116
+ assert "changed from 1 to 2 on 2026-10-04" in notes and "replies" in notes
117
+
118
+
119
+ def test_outputs_escape_report_text(tmp_path):
120
+ hostile = "<script>alert(1)</script>|x"
121
+ obj = report(0, source=hostile)
122
+ obj["body"][0]["segment"] = [hostile]
123
+ write(tmp_path / "r.json", obj)
124
+ loaded = load([tmp_path])
125
+ page = to_html(loaded)
126
+ assert "<script>" not in page and "&lt;script&gt;" in page
127
+ md = to_markdown(loaded)
128
+ assert "</script>\\|x" in md and "</script>|x" not in md
129
+
130
+
131
+ def test_cli(tmp_path, capsys):
132
+ out = tmp_path / "out.html"
133
+ assert main([str(FIX), "--html", str(out)]) == 0
134
+ stdout = capsys.readouterr().out
135
+ assert "Receiver MBP, Inc.: example.com, selector selector1" in stdout
136
+ assert "| 2024-02-29 | Seg1 / Seg2 / Seg3 | 10,000 | 500 | 300 | 50 | 200 |" in stdout
137
+ assert out.read_text().startswith("<!doctype html>")
138
+ (tmp_path / "empty").mkdir()
139
+ assert main([str(tmp_path / "empty")]) == 1