aprf-reader 0.2.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -15,9 +15,16 @@ Dynamic: license-file
15
15
 
16
16
  Reads APRF (Aggregate Performance Reporting) reports and tells you where the
17
17
  mail you signed landed: how much reached the inbox, how much was treated as
18
- unwanted, and how recipients reacted, day by day and per DKIM selector.
18
+ unwanted, and how recipients reacted, day by day and per DKIM selector. It
19
+ points out the days worth a look, such as a jump in unwanted mail.
19
20
 
20
- **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
+ Two ways to use it, with the same results:
22
+
23
+ - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
+ your reports on the page. They are read on your computer and never uploaded.
25
+ - **On the command line:** `pipx install aprf-reader`.
26
+
27
+ **Status: 0.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
28
 
22
29
  ## What APRF is
23
30
 
@@ -39,11 +46,18 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
39
46
 
40
47
  ```bash
41
48
  pipx install aprf-reader
42
- aprf-reader reports/ # Markdown summary to the terminal
43
- aprf-reader reports/ --html summary.html # and a page you can open or share
49
+ aprf-reader reports/ # overview in the terminal
44
50
  aprf-reader APRF.mbox # a mail folder exported as mbox
51
+ aprf-reader reports/ --markdown # Markdown with daily tables
52
+ aprf-reader reports/ --html summary.html # and a page you can open or share
53
+ aprf-reader reports/ --json # the summary as data, for scripts
45
54
  ```
46
55
 
56
+ In a terminal you get an overview: for each stream, each count's latest value,
57
+ its daily range, and a trend line with a gap for each day with no report, then
58
+ the changes worth a look. Piped or written to a file with `--md`, the output
59
+ is Markdown with the full daily tables.
60
+
47
61
  Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
48
62
  mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
49
63
  is a mail filter that moves them into one folder, then an export of that folder:
@@ -55,6 +69,10 @@ on stderr and at the end of the summary, each with a reason.
55
69
 
56
70
  Each stream (provider, signing domain, selector) gets a table with one row per
57
71
  day and segment, a trend line per count, and a row for each day with no report.
72
+ A change worth a look is a day when unwanted mail or negative reactions at
73
+ least doubled and grew by 200 or more, or inbox mail fell by half and by 200
74
+ or more, against the report before. Comcast counts in steps of 100, so smaller
75
+ moves are left out as rounding.
58
76
  A change in the report's `version` field is called out, since the draft uses it
59
77
  to mark a change in what a category counts.
60
78
 
@@ -73,6 +91,17 @@ to mark a change in what a category counts.
73
91
  - When the same day arrives twice, keep the first copy and say so. The draft
74
92
  has not yet decided whether a resend replaces the original.
75
93
 
94
+ ## How the web page is built
95
+
96
+ `web/src/aprf.js` is a JavaScript port of the Python reader.
97
+ `tests/test_web_parity.py` runs both on the same files (JSON, gzip, saved
98
+ emails, forwarded emails, mailbox exports, duplicates and hostile text) and
99
+ requires the same JSON, Markdown and HTML output, so the page and the command
100
+ line cannot drift apart. `node web/build.js` inlines everything into one
101
+ `web/dist/index.html` whose Content Security Policy blocks every network
102
+ request, so it also works saved to disk. A merge to main that passes the
103
+ tests deploys it to Cloudflare.
104
+
76
105
  ## Related
77
106
 
78
107
  - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
@@ -2,9 +2,16 @@
2
2
 
3
3
  Reads APRF (Aggregate Performance Reporting) reports and tells you where the
4
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.
5
+ unwanted, and how recipients reacted, day by day and per DKIM selector. It
6
+ points out the days worth a look, such as a jump in unwanted mail.
6
7
 
7
- **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
8
+ Two ways to use it, with the same results:
9
+
10
+ - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
11
+ your reports on the page. They are read on your computer and never uploaded.
12
+ - **On the command line:** `pipx install aprf-reader`.
13
+
14
+ **Status: 0.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
8
15
 
9
16
  ## What APRF is
10
17
 
@@ -26,11 +33,18 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
26
33
 
27
34
  ```bash
28
35
  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
36
+ aprf-reader reports/ # overview in the terminal
31
37
  aprf-reader APRF.mbox # a mail folder exported as mbox
38
+ aprf-reader reports/ --markdown # Markdown with daily tables
39
+ aprf-reader reports/ --html summary.html # and a page you can open or share
40
+ aprf-reader reports/ --json # the summary as data, for scripts
32
41
  ```
33
42
 
43
+ In a terminal you get an overview: for each stream, each count's latest value,
44
+ its daily range, and a trend line with a gap for each day with no report, then
45
+ the changes worth a look. Piped or written to a file with `--md`, the output
46
+ is Markdown with the full daily tables.
47
+
34
48
  Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
35
49
  mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
36
50
  is a mail filter that moves them into one folder, then an export of that folder:
@@ -42,6 +56,10 @@ on stderr and at the end of the summary, each with a reason.
42
56
 
43
57
  Each stream (provider, signing domain, selector) gets a table with one row per
44
58
  day and segment, a trend line per count, and a row for each day with no report.
59
+ A change worth a look is a day when unwanted mail or negative reactions at
60
+ least doubled and grew by 200 or more, or inbox mail fell by half and by 200
61
+ or more, against the report before. Comcast counts in steps of 100, so smaller
62
+ moves are left out as rounding.
45
63
  A change in the report's `version` field is called out, since the draft uses it
46
64
  to mark a change in what a category counts.
47
65
 
@@ -60,6 +78,17 @@ to mark a change in what a category counts.
60
78
  - When the same day arrives twice, keep the first copy and say so. The draft
61
79
  has not yet decided whether a resend replaces the original.
62
80
 
81
+ ## How the web page is built
82
+
83
+ `web/src/aprf.js` is a JavaScript port of the Python reader.
84
+ `tests/test_web_parity.py` runs both on the same files (JSON, gzip, saved
85
+ emails, forwarded emails, mailbox exports, duplicates and hostile text) and
86
+ requires the same JSON, Markdown and HTML output, so the page and the command
87
+ line cannot drift apart. `node web/build.js` inlines everything into one
88
+ `web/dist/index.html` whose Content Security Policy blocks every network
89
+ request, so it also works saved to disk. A merge to main that passes the
90
+ tests deploys it to Cloudflare.
91
+
63
92
  ## Related
64
93
 
65
94
  - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Summarise APRF (Aggregate Performance Reporting) inbox placement reports."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """Read APRF aggregate performance reports."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -3,19 +3,25 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
7
+ import os
8
+ import shutil
6
9
  import sys
7
10
  from pathlib import Path
8
11
 
9
12
  from . import __version__
10
13
  from .load import load
11
- from .render import to_html, to_markdown
14
+ from .render import to_html, to_markdown, to_terminal
15
+ from .summary import to_dict
12
16
 
13
17
 
14
18
  def main(argv: list[str] | None = None) -> int:
15
19
  p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
16
20
  p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml, .mbox) or folders")
17
- p.add_argument("--md", type=Path, help="write the Markdown summary here instead of printing it")
21
+ p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
18
22
  p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
23
+ p.add_argument("--markdown", action="store_true", help="print Markdown with daily tables, even in a terminal")
24
+ p.add_argument("--json", action="store_true", help="print the summary as JSON")
19
25
  p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
20
26
  args = p.parse_args(argv)
21
27
 
@@ -29,11 +35,17 @@ def main(argv: list[str] | None = None) -> int:
29
35
  print("no APRF reports found", file=sys.stderr)
30
36
  return 1
31
37
 
32
- md = to_markdown(loaded)
38
+ if args.json:
39
+ text = json.dumps(to_dict(loaded.reports, loaded.skipped), indent=2) + "\n"
40
+ elif args.md or args.markdown or not sys.stdout.isatty():
41
+ text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
42
+ else:
43
+ color = "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
44
+ text = to_terminal(loaded, color=color, width=shutil.get_terminal_size().columns)
33
45
  if args.md:
34
- args.md.write_text(md, encoding="utf-8")
46
+ args.md.write_text(text, encoding="utf-8")
35
47
  else:
36
- sys.stdout.write(md)
48
+ sys.stdout.write(text)
37
49
  if args.html:
38
50
  args.html.write_text(to_html(loaded), encoding="utf-8")
39
51
  return 0
@@ -14,7 +14,7 @@ from email.message import Message
14
14
  from email.parser import BytesParser
15
15
  from pathlib import Path
16
16
 
17
- from .report import Report, ReportError, parse
17
+ from .report import Report, ReportError, parse_items
18
18
 
19
19
  SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
20
20
  # A daily report is a few KB; this stops a stray huge file or gzip bomb
@@ -116,16 +116,20 @@ def load(paths: Iterable[Path]) -> Loaded:
116
116
  return
117
117
  seen_bytes.add(digest)
118
118
  try:
119
- reports = parse(data)
119
+ items = parse_items(data)
120
120
  except ReportError as e:
121
121
  out.skipped.append((where, str(e)))
122
122
  return
123
- for r in reports:
123
+ for i, r in enumerate(items, 1):
124
+ label = f"{where} report {i}" if len(items) > 1 else where
125
+ if isinstance(r, ReportError):
126
+ out.skipped.append((label, str(r)))
127
+ continue
124
128
  # The draft does not yet say whether a resend replaces the first; keep the first
125
129
  if r.key in seen_keys:
126
- out.skipped.append((where, f"same day already reported in {seen_keys[r.key]}; kept that one"))
130
+ out.skipped.append((label, f"same day already reported in {seen_keys[r.key]}; kept that one"))
127
131
  continue
128
- seen_keys[r.key] = where
132
+ seen_keys[r.key] = label
129
133
  out.reports.append(r)
130
134
 
131
135
  for path in _files(paths):
@@ -3,12 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import html
6
+ import re
7
+ from datetime import date
6
8
 
7
9
  from . import __version__
8
10
  from .load import Loaded
9
11
  from .report import DRAFT
10
12
  from .summary import Row, Stream, streams
11
13
 
14
+ NO_COUNTS = "These reports carry no counts."
15
+
12
16
  CAVEAT = (
13
17
  "Counts are bucket upper bounds chosen by each provider: a ceiling, not an exact figure. "
14
18
  "Placement counts mail received that day; engagement counts actions taken that day on mail "
@@ -48,6 +52,9 @@ def to_markdown(loaded: Loaded) -> str:
48
52
  for s in ss:
49
53
  lines += [f"## {_md(s.source)}: {_md(s.domain)}, selector {_md(s.selector)}", "", _span(s), ""]
50
54
  cols = [k for _, k in s.columns]
55
+ if not cols:
56
+ lines += [NO_COUNTS, ""]
57
+ continue
51
58
  lines.append("| Day | Segment | " + " | ".join(_md(c) for c in cols) + " |")
52
59
  lines.append("|---|---|" + "---:|" * len(cols))
53
60
  for row in s.rows:
@@ -56,6 +63,8 @@ def to_markdown(loaded: Loaded) -> str:
56
63
  else:
57
64
  lines.append(f"| {row.day} | {_md(row.segment)} | " + " | ".join(_n(v) for v in row.values) + " |")
58
65
  lines.append("")
66
+ if s.changes:
67
+ lines += ["Changes worth a look:", ""] + [f"- {_md(c)}" for c in s.changes] + [""]
59
68
  if s.notes:
60
69
  lines += [f"- {_md(n)}" for n in s.notes] + [""]
61
70
  if loaded.skipped:
@@ -108,6 +117,9 @@ def to_html(loaded: Loaded) -> str:
108
117
  for s in ss:
109
118
  cols = [k for _, k in s.columns]
110
119
  parts.append(f"<h2>{e(s.source)}: {e(s.domain)}, selector {e(s.selector)}</h2>")
120
+ if not cols:
121
+ parts.append(f"<p class=muted>{e(_span(s))}</p><p>{NO_COUNTS}</p>")
122
+ continue
111
123
  parts.append(f"<p class=muted>{e(_span(s))}</p><div class=wrap><table><thead><tr><th>Day<th>Segment")
112
124
  parts.append("".join(f"<th>{e(c)}" for c in cols) + "</thead><tbody>")
113
125
  parts.append("<tr class=trend><td>Trend<td class=muted>sum per day")
@@ -118,6 +130,8 @@ def to_html(loaded: Loaded) -> str:
118
130
  else:
119
131
  parts.append(f"<tr><td>{row.day}<td>{e(row.segment)}" + "".join(f"<td>{_n(v)}" for v in row.values))
120
132
  parts.append("</tbody></table></div>")
133
+ if s.changes:
134
+ parts.append("<p>Changes worth a look:</p><ul>" + "".join(f"<li>{e(c)}" for c in s.changes) + "</ul>")
121
135
  if s.notes:
122
136
  parts.append("<ul>" + "".join(f"<li>{e(n)}" for n in s.notes) + "</ul>")
123
137
  if loaded.skipped:
@@ -125,3 +139,103 @@ def to_html(loaded: Loaded) -> str:
125
139
  parts.append(f"<p class=muted>Read by aprf-reader {e(__version__)} against {DRAFT}. Nothing left this computer.</p>")
126
140
  parts.append("</body></html>")
127
141
  return "\n".join(parts) + "\n"
142
+
143
+
144
+ # ---- terminal view ----
145
+
146
+ BARS = "▁▂▃▄▅▆▇█"
147
+
148
+
149
+ class _Style:
150
+ def __init__(self, color: bool):
151
+ self.on = color
152
+
153
+ def __call__(self, code: str, text: str) -> str:
154
+ return f"\033[{code}m{text}\033[0m" if self.on else text
155
+
156
+ def bold(self, t: str) -> str: return self("1", t)
157
+ def dim(self, t: str) -> str: return self("2", t)
158
+ def accent(self, t: str) -> str: return self("1;34", t)
159
+ def warn(self, t: str) -> str: return self("33", t)
160
+ def good(self, t: str) -> str: return self("32", t)
161
+
162
+
163
+ def _bars(points: list[tuple[int, int]], width: int) -> str:
164
+ """One character per day, a space for a day with no report; days share a cell when they do not fit."""
165
+ span = points[-1][0] + 1
166
+ cells: list[int | None] = [None] * min(span, width)
167
+ for x, v in points:
168
+ i = x * len(cells) // span
169
+ cells[i] = v if cells[i] is None else max(cells[i], v)
170
+ top = max(v for _, v in points) or 1
171
+ if min(v for _, v in points) == top:
172
+ # A flat line is the same every day; a full bar would read as a peak
173
+ return "".join(" " if v is None else BARS[3] for v in cells)
174
+ return "".join(" " if v is None else BARS[min(7, v * 7 // top)] if v else BARS[0] for v in cells)
175
+
176
+
177
+ def _spread(points: list[tuple[int, int]]) -> str:
178
+ lo, hi = min(v for _, v in points), max(v for _, v in points)
179
+ if lo == hi:
180
+ return f"{lo:,}" if len(points) == 1 else f"{lo:,} every day"
181
+ return f"{lo:,} to {hi:,}"
182
+
183
+
184
+ def _date(d) -> str:
185
+ return f"{d:%b} {d.day}"
186
+
187
+
188
+ def _range(a, b) -> str:
189
+ if a == b:
190
+ return f"{_date(a)}, {a.year}"
191
+ return (_date(a) if a.year == b.year else f"{_date(a)}, {a.year}") + f" to {_date(b)}, {b.year}"
192
+
193
+
194
+ def _wrap(text: str, width: int, indent: str = "") -> list[str]:
195
+ import textwrap
196
+ return textwrap.wrap(text, width=max(40, width), initial_indent=indent, subsequent_indent=indent) or [indent]
197
+
198
+
199
+ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
200
+ """A readable overview for a terminal: per stream, each count's latest value, range and trend."""
201
+ st = _Style(color)
202
+ ss = streams(loaded.reports)
203
+ out = [st.bold("APRF placement summary") + st.dim(f" {_counts(loaded, ss)}"), ""]
204
+ out += [st.dim(line) for line in _wrap(CAVEAT, min(width, 100))] + [""]
205
+ for s in ss:
206
+ out.append(st.accent(s.domain) + " " + st.bold(f"selector {s.selector}"))
207
+ meta = [s.source, _range(s.first, s.last), _plural(len(s.by_day), "report")]
208
+ line = st.dim(" · ".join(meta))
209
+ if s.missing:
210
+ line += st.dim(" · ") + st.warn(f"{_plural(s.missing, 'day')} with no report")
211
+ out += [line, ""]
212
+ names = [k for _, k in s.columns]
213
+ if not names:
214
+ out += [" " + st.dim(NO_COUNTS), ""]
215
+ continue
216
+ nw = max([len(n) for n in names] + [5])
217
+ trends = [s.trend(i) for i in range(len(names))]
218
+ latest = [f"{t[-1][1]:,}" for t in trends]
219
+ ranges = [_spread(t) for t in trends]
220
+ lw, rw = max(len(x) for x in latest + ["latest"]), max(len(x) for x in ranges + ["range"])
221
+ bw = max(10, min(60, width - (2 + nw + 2 + lw + 2 + rw + 2)))
222
+ out.append(st.dim(f" {'':<{nw}} {'latest':>{lw}} {'range':<{rw}} trend"))
223
+ for name, t, l, r in zip(names, trends, latest, ranges):
224
+ out.append(f" {name:<{nw}} {st.bold(f'{l:>{lw}}')} {r:<{rw}} {st.accent(_bars(t, bw)) if len(t) > 1 else st.dim('one day')}")
225
+ out.append("")
226
+ if s.changes:
227
+ out.append(st.bold(" Changes worth a look"))
228
+ out += [f" {st.warn('!')} " + re.sub(r"(\d{4})-(\d\d)-(\d\d)", lambda m: _date(date.fromisoformat(m[0])), c)
229
+ for c in s.changes]
230
+ else:
231
+ out.append(f" {st.good('✓')} " + st.dim("No big day-to-day moves in inbox, unwanted, or negative counts."))
232
+ for n in s.notes:
233
+ out += _wrap(n, width - 4, " · ")
234
+ out.append("")
235
+ if loaded.skipped:
236
+ out.append(st.warn(f"Skipped {len(loaded.skipped)}"))
237
+ for w, r in loaded.skipped:
238
+ out += [st.dim(line) for line in _wrap(f"{w}: {r}", width - 2, " ")]
239
+ out.append("")
240
+ out.append(st.dim(f"aprf-reader {__version__}, {DRAFT}. Daily tables: --markdown, or --html for a page."))
241
+ return "\n".join(out) + "\n"
@@ -15,10 +15,16 @@ DRAFT = "draft-ietf-mailmaint-aprf-00"
15
15
  # Names the draft defines, in display order; providers may add others
16
16
  PLACEMENT_NAMES = ("inbox", "unwanted")
17
17
  ENGAGEMENT_NAMES = ("positive", "neutral", "negative")
18
- # sdi_used values (first word) that mean no SDI was applied (section 5.2.1)
18
+ # sdi_used values that mean no SDI was applied (section 5.2.1), compared on
19
+ # the first word without case or trailing punctuation
19
20
  NO_SDI = ("", "N/A", "N/F")
20
21
 
21
22
 
23
+ def uses_sdi(value: str) -> bool:
24
+ first = value.split(" ", 1)[0].rstrip(";,.").upper()
25
+ return first not in NO_SDI
26
+
27
+
22
28
  class ReportError(ValueError):
23
29
  """The data is not an APRF report this module can read."""
24
30
 
@@ -50,15 +56,45 @@ class Report:
50
56
  # Section 8: the report's date is its ending date, in UTC
51
57
  return _day(self.report_end)
52
58
 
59
+ @property
60
+ def stream_key(self) -> tuple[str, str, str]:
61
+ return (self.source, name_key(self.dkim_domain), self.dkim_selector.lower())
62
+
53
63
  @property
54
64
  def key(self) -> tuple[str, str, str, date]:
55
- return (self.source, self.dkim_domain.lower(), self.dkim_selector, self.day)
65
+ return (*self.stream_key, self.day)
66
+
67
+
68
+ def name_key(domain: str) -> str:
69
+ """DNS names compare without case, and bücher.example is xn--bcher-kva.example."""
70
+ d = domain.lower()
71
+ # Only plain letters, digits, dots and hyphens; the web page converts the same set
72
+ # shortcut: Python uses IDNA 2003 and browsers UTS 46, which differ on a few letters such as ß
73
+ if d.isascii() or not all(c.isalnum() or c in ".-" for c in d):
74
+ return d
75
+ try:
76
+ return d.encode("idna").decode("ascii")
77
+ except UnicodeError:
78
+ return d
56
79
 
57
80
 
58
81
  def _day(epoch: int) -> date:
59
82
  return datetime.fromtimestamp(epoch, UTC).date()
60
83
 
61
84
 
85
+ # JavaScript numbers are exact only up to here; the web page reads the same files
86
+ MAX_COUNT = 2**53 - 1
87
+
88
+
89
+ def _int(v: object) -> int | None:
90
+ """An integer, or a float with nothing after the point (100.0); otherwise None."""
91
+ if isinstance(v, bool):
92
+ return None
93
+ if isinstance(v, float) and v.is_integer():
94
+ v = int(v)
95
+ return v if isinstance(v, int) else None
96
+
97
+
62
98
  def _counts(value: object, where: str) -> dict[str, int]:
63
99
  if value is None:
64
100
  return {}
@@ -66,9 +102,10 @@ def _counts(value: object, where: str) -> dict[str, int]:
66
102
  raise ReportError(f"{where} is not an object")
67
103
  out = {}
68
104
  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
105
+ k = _int(n)
106
+ if k is None or k < 0 or k > MAX_COUNT:
107
+ raise ReportError(f"{where}.{name} is not a count: {json.dumps(n, ensure_ascii=False)}")
108
+ out[str(name)] = k
72
109
  return out
73
110
 
74
111
 
@@ -103,13 +140,19 @@ def _report(obj: object) -> Report:
103
140
  for name in ("dkim_domain", "dkim_selector"):
104
141
  if not isinstance(h.get(name), str) or not h[name]:
105
142
  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):
143
+ start, end = _int(h.get("report_start")), _int(h.get("report_end"))
144
+ for name, v in (("report_start", start), ("report_end", end)):
145
+ if v is None:
108
146
  raise ReportError(f"header.{name} is not an epoch time")
147
+ # Seconds past 1e11 are beyond the year 5138; up to 1e14 that is milliseconds
148
+ if 10**11 <= abs(v) < 10**14:
149
+ raise ReportError(f"header.{name} looks like milliseconds, not seconds: {v}")
150
+ if end < start:
151
+ raise ReportError("header.report_end is before report_start")
109
152
  try:
110
- _day(h["report_end"])
153
+ _day(end)
111
154
  except (OverflowError, OSError, ValueError):
112
- raise ReportError(f"header.report_end is out of range: {h['report_end']}") from None
155
+ raise ReportError(f"header.report_end is out of range: {end}") from None
113
156
  body = obj.get("body", [])
114
157
  if not isinstance(body, list):
115
158
  raise ReportError("body is not a list")
@@ -117,8 +160,8 @@ def _report(obj: object) -> Report:
117
160
  source=_text(h.get("source")) or "unknown",
118
161
  dkim_domain=h["dkim_domain"],
119
162
  dkim_selector=h["dkim_selector"],
120
- report_start=h["report_start"],
121
- report_end=h["report_end"],
163
+ report_start=start,
164
+ report_end=end,
122
165
  version=_text(h.get("version")),
123
166
  # Comcast's beta sends " - N/A - Beta - Not in use"
124
167
  sdi_used=_text(h.get("sdi_used")).strip(" -"),
@@ -128,8 +171,9 @@ def _report(obj: object) -> Report:
128
171
  )
129
172
 
130
173
 
131
- def parse(data: bytes) -> list[Report]:
132
- """Parse one report document. The draft's samples wrap reports in a list."""
174
+ def parse_items(data: bytes) -> list[Report | ReportError]:
175
+ """Each report in a document, or the reason it cannot be read, so one bad
176
+ report does not cost the rest of the file."""
133
177
  try:
134
178
  doc = json.loads(data)
135
179
  except (UnicodeDecodeError, json.JSONDecodeError) as e:
@@ -139,4 +183,19 @@ def parse(data: bytes) -> list[Report]:
139
183
  items = doc if isinstance(doc, list) else [doc]
140
184
  if not items:
141
185
  raise ReportError("empty list")
142
- return [_report(o) for o in items]
186
+ out: list[Report | ReportError] = []
187
+ for o in items:
188
+ try:
189
+ out.append(_report(o))
190
+ except ReportError as e:
191
+ out.append(e)
192
+ return out
193
+
194
+
195
+ def parse(data: bytes) -> list[Report]:
196
+ """Parse one report document. The draft's samples wrap reports in a list."""
197
+ items = parse_items(data)
198
+ for r in items:
199
+ if isinstance(r, ReportError):
200
+ raise r
201
+ return items # type: ignore[return-value]
@@ -7,7 +7,12 @@ from dataclasses import dataclass
7
7
  from datetime import date, timedelta
8
8
  from functools import cached_property
9
9
 
10
- from .report import ENGAGEMENT_NAMES, NO_SDI, PLACEMENT_NAMES, Report
10
+ from .report import ENGAGEMENT_NAMES, PLACEMENT_NAMES, Report, uses_sdi
11
+
12
+
13
+ # Counts where a jump up is bad news, or a drop is
14
+ CHANGES = {"unwanted": "rise", "negative": "rise", "inbox": "fall"}
15
+ MIN_MOVE = 200
11
16
 
12
17
 
13
18
  @dataclass(frozen=True)
@@ -59,7 +64,7 @@ class Stream:
59
64
  out.append(Row(prev + timedelta(1), d - timedelta(1), None))
60
65
  for s in self.by_day[d].segments:
61
66
  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))
67
+ out.append(Row(d, d, " / ".join(p or "(blank)" for p in s.path) or "all mail", vals))
63
68
  prev = d
64
69
  return out
65
70
 
@@ -69,6 +74,27 @@ class Stream:
69
74
  return [((d - self.first).days, sum(getattr(s, a).get(k, 0) for s in r.segments))
70
75
  for d, r in sorted(self.by_day.items())]
71
76
 
77
+ @cached_property
78
+ def changes(self) -> list[str]:
79
+ """Day-to-day moves big enough to look at, between one report day and the next."""
80
+ out = []
81
+ for col, (_, k) in enumerate(self.columns):
82
+ rule = CHANGES.get(k)
83
+ if rule is None:
84
+ continue
85
+ pts = self.trend(col)
86
+ for (x0, old), (x1, new) in zip(pts, pts[1:]):
87
+ # Comcast's buckets are 100 wide, so a move under two buckets is noise
88
+ if rule == "rise" and new >= 2 * old and new - old >= MIN_MOVE:
89
+ verb = "rose"
90
+ elif rule == "fall" and 2 * new <= old and old - new >= MIN_MOVE:
91
+ verb = "fell"
92
+ else:
93
+ continue
94
+ d0, d1 = self.first + timedelta(x0), self.first + timedelta(x1)
95
+ out.append(f"{k} {verb} from {old:,} on {d0} to {new:,} on {d1}.")
96
+ return sorted(out, key=lambda t: t.rsplit(" on ", 1)[1])
97
+
72
98
  @cached_property
73
99
  def notes(self) -> list[str]:
74
100
  out = []
@@ -80,8 +106,7 @@ class Stream:
80
106
  prev = r.version
81
107
  # "TBD" is the draft samples' placeholder
82
108
  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()
84
- if r.sdi_used.split(" ", 1)[0] not in NO_SDI})
109
+ sdi = sorted({r.sdi_used for r in self.by_day.values() if uses_sdi(r.sdi_used)})
85
110
  if sdi:
86
111
  out.append("SDI used: " + ", ".join(sdi))
87
112
  return out
@@ -90,5 +115,22 @@ class Stream:
90
115
  def streams(reports: list[Report]) -> list[Stream]:
91
116
  groups: dict[tuple[str, str, str], dict[date, Report]] = defaultdict(dict)
92
117
  for r in reports:
93
- groups[(r.source, r.dkim_domain.lower(), r.dkim_selector)][r.day] = r
118
+ groups[r.stream_key][r.day] = r
94
119
  return [Stream(src, dom, sel, days) for (src, dom, sel), days in sorted(groups.items())]
120
+
121
+
122
+ def to_dict(reports: list[Report], skipped: list[tuple[str, str]]) -> dict:
123
+ """The summary as plain data, for --json and for checking the web page against."""
124
+ return {
125
+ "reports": len(reports),
126
+ "streams": [{
127
+ "source": s.source, "domain": s.domain, "selector": s.selector,
128
+ "first": str(s.first), "last": str(s.last), "missing_days": s.missing,
129
+ "columns": [k for _, k in s.columns],
130
+ "days": [{"day": str(d), "version": r.version, "segments": [
131
+ {"segment": list(seg.path), "classification": seg.classification, "engagement": seg.engagement}
132
+ for seg in r.segments]} for d, r in sorted(s.by_day.items())],
133
+ "changes": s.changes, "notes": s.notes,
134
+ } for s in streams(reports)],
135
+ "skipped": [{"where": w, "reason": r} for w, r in skipped],
136
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -15,9 +15,16 @@ Dynamic: license-file
15
15
 
16
16
  Reads APRF (Aggregate Performance Reporting) reports and tells you where the
17
17
  mail you signed landed: how much reached the inbox, how much was treated as
18
- unwanted, and how recipients reacted, day by day and per DKIM selector.
18
+ unwanted, and how recipients reacted, day by day and per DKIM selector. It
19
+ points out the days worth a look, such as a jump in unwanted mail.
19
20
 
20
- **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
+ Two ways to use it, with the same results:
22
+
23
+ - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
+ your reports on the page. They are read on your computer and never uploaded.
25
+ - **On the command line:** `pipx install aprf-reader`.
26
+
27
+ **Status: 0.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
28
 
22
29
  ## What APRF is
23
30
 
@@ -39,11 +46,18 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
39
46
 
40
47
  ```bash
41
48
  pipx install aprf-reader
42
- aprf-reader reports/ # Markdown summary to the terminal
43
- aprf-reader reports/ --html summary.html # and a page you can open or share
49
+ aprf-reader reports/ # overview in the terminal
44
50
  aprf-reader APRF.mbox # a mail folder exported as mbox
51
+ aprf-reader reports/ --markdown # Markdown with daily tables
52
+ aprf-reader reports/ --html summary.html # and a page you can open or share
53
+ aprf-reader reports/ --json # the summary as data, for scripts
45
54
  ```
46
55
 
56
+ In a terminal you get an overview: for each stream, each count's latest value,
57
+ its daily range, and a trend line with a gap for each day with no report, then
58
+ the changes worth a look. Piped or written to a file with `--md`, the output
59
+ is Markdown with the full daily tables.
60
+
47
61
  Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
48
62
  mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
49
63
  is a mail filter that moves them into one folder, then an export of that folder:
@@ -55,6 +69,10 @@ on stderr and at the end of the summary, each with a reason.
55
69
 
56
70
  Each stream (provider, signing domain, selector) gets a table with one row per
57
71
  day and segment, a trend line per count, and a row for each day with no report.
72
+ A change worth a look is a day when unwanted mail or negative reactions at
73
+ least doubled and grew by 200 or more, or inbox mail fell by half and by 200
74
+ or more, against the report before. Comcast counts in steps of 100, so smaller
75
+ moves are left out as rounding.
58
76
  A change in the report's `version` field is called out, since the draft uses it
59
77
  to mark a change in what a category counts.
60
78
 
@@ -73,6 +91,17 @@ to mark a change in what a category counts.
73
91
  - When the same day arrives twice, keep the first copy and say so. The draft
74
92
  has not yet decided whether a resend replaces the original.
75
93
 
94
+ ## How the web page is built
95
+
96
+ `web/src/aprf.js` is a JavaScript port of the Python reader.
97
+ `tests/test_web_parity.py` runs both on the same files (JSON, gzip, saved
98
+ emails, forwarded emails, mailbox exports, duplicates and hostile text) and
99
+ requires the same JSON, Markdown and HTML output, so the page and the command
100
+ line cannot drift apart. `node web/build.js` inlines everything into one
101
+ `web/dist/index.html` whose Content Security Policy blocks every network
102
+ request, so it also works saved to disk. A merge to main that passes the
103
+ tests deploys it to Cloudflare.
104
+
76
105
  ## Related
77
106
 
78
107
  - [dns-audit.com](https://dns-audit.com) checks a domain for an APRF record and
@@ -13,4 +13,5 @@ src/aprf_reader.egg-info/dependency_links.txt
13
13
  src/aprf_reader.egg-info/entry_points.txt
14
14
  src/aprf_reader.egg-info/requires.txt
15
15
  src/aprf_reader.egg-info/top_level.txt
16
- tests/test_reader.py
16
+ tests/test_reader.py
17
+ tests/test_web_parity.py
@@ -1,6 +1,7 @@
1
1
  import gzip
2
2
  import json
3
3
  import mailbox
4
+ import re
4
5
  from datetime import date
5
6
  from email.message import EmailMessage
6
7
  from pathlib import Path
@@ -187,3 +188,70 @@ def test_cli(tmp_path, capsys):
187
188
  assert out.read_text().startswith("<!doctype html>")
188
189
  (tmp_path / "empty").mkdir()
189
190
  assert main([str(tmp_path / "empty")]) == 1
191
+
192
+
193
+ def test_changes_flag_big_moves_only():
194
+ (s1, s2) = streams(parse((FIX / "sample_six_weeks.json").read_bytes()))
195
+ assert s1.selector == "news" and s1.changes == [
196
+ "unwanted rose from 300 on 2026-09-24 to 2,500 on 2026-09-25.",
197
+ "negative rose from 100 on 2026-09-24 to 400 on 2026-09-25.",
198
+ "inbox fell from 2,200 on 2026-09-25 to 600 on 2026-09-26.",
199
+ ]
200
+ assert s2.changes == [] # steady stream, nothing flagged
201
+ # 10 to 200 is many times higher but under two buckets of movement
202
+ (s,) = streams([parse(json.dumps(report(0, inbox=100)).encode())[0],
203
+ parse(json.dumps(report(1, inbox=100) | {"body": [{"classification": {"unwanted": 200}}]}).encode())[0]])
204
+ assert not any("unwanted" in c for c in s.changes)
205
+
206
+
207
+ def test_json_output(capsys):
208
+ assert main([str(FIX / "sample_six_weeks.json"), "--json"]) == 0
209
+ doc = json.loads(capsys.readouterr().out)
210
+ assert doc["reports"] == 80 and [s["selector"] for s in doc["streams"]] == ["news", "receipts"]
211
+ news = doc["streams"][0]
212
+ assert news["missing_days"] == 2 and news["columns"] == ["inbox", "unwanted", "positive", "negative"]
213
+ assert news["days"][0]["segments"][0]["segment"] == [] and len(news["changes"]) == 3
214
+
215
+
216
+ def test_terminal_view():
217
+ from aprf_reader.render import to_terminal
218
+ loaded = load([FIX / "sample_six_weeks.json", FIX / "comcast_beta.json"])
219
+ out = to_terminal(loaded, color=False, width=100)
220
+ assert "example.com selector news" in out and "2 days with no report" in out
221
+ assert "! unwanted rose from 300 on Sep 24 to 2,500 on Sep 25." in out
222
+ assert "✓ No big day-to-day moves" in out and "one day" in out
223
+ assert "\033[" not in out and "\033[" in to_terminal(loaded, color=True)
224
+ assert max(len(line) for line in out.splitlines()) <= 100
225
+ inbox = next(line for line in out.splitlines() if line.startswith(" inbox"))
226
+ assert re.search("[▁-█] +[▁-█]", inbox) # the two days with no report show as a break
227
+
228
+
229
+ def test_cli_terminal_when_interactive(monkeypatch, capsys):
230
+ monkeypatch.setattr("sys.stdout.isatty", lambda: True)
231
+ monkeypatch.setenv("NO_COLOR", "1")
232
+ assert main([str(FIX / "sample_six_weeks.json")]) == 0
233
+ out = capsys.readouterr().out
234
+ assert out.startswith("APRF placement summary") and "\033[" not in out
235
+ assert main([str(FIX / "sample_six_weeks.json"), "--markdown"]) == 0
236
+ assert capsys.readouterr().out.startswith("# APRF placement summary")
237
+
238
+
239
+ def test_stress_set_one_bad_report_does_not_cost_the_file():
240
+ loaded = load([FIX / "stress_100.json"])
241
+ assert len(loaded.reports) == 88
242
+ reasons = {w.rsplit(" ", 1)[-1]: r for w, r in loaded.skipped}
243
+ assert reasons["73"] == 'body[0].engagement.positive is not a count: "100"'
244
+ assert reasons["79"] == "header.report_end is before report_start"
245
+ assert reasons["81"].startswith("header.report_start looks like milliseconds")
246
+ assert "report 63" in reasons["64"] # bücher.example is xn--bcher-kva.example
247
+ assert "report 3" in reasons["70"] # DNS-AUDIT.COM / SELECTOR1 is dns-audit.com / selector1
248
+ ss = {(s.source, s.domain, s.selector): s for s in streams(loaded.reports)}
249
+ obj = report(0)
250
+ obj["body"][0]["classification"]["unwanted"] = 100.0
251
+ assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
252
+ for sel in ("sdi0", "sdi1", "sdi2", "sdi3", "sdi4"): # n/a, "N/A ", "", null, N/F;
253
+ assert not ss[("Comcast/Xfinity", "dns-audit.com", sel)].notes, sel
254
+ blank = ss[("Comcast/Xfinity", "dns-audit.com", "s13")]
255
+ assert "(blank)" in [r.segment for r in blank.rows]
256
+ assert not ss[("Comcast/Xfinity", "dns-audit.com", "s8")].columns
257
+ assert "These reports carry no counts." in to_markdown(loaded)
@@ -0,0 +1,106 @@
1
+ """The web page (web/src/aprf.js) must read files exactly as the command line does."""
2
+
3
+ import gzip
4
+ import json
5
+ import mailbox
6
+ import shutil
7
+ import subprocess
8
+ from email.message import EmailMessage
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from aprf_reader import __version__
14
+ from aprf_reader.load import load
15
+ from aprf_reader.render import to_html, to_markdown
16
+ from aprf_reader.summary import to_dict
17
+
18
+ ROOT = Path(__file__).parent.parent
19
+ FIX = Path(__file__).parent / "fixtures"
20
+ NODE = shutil.which("node")
21
+
22
+ pytestmark = pytest.mark.skipif(NODE is None, reason="node is not installed")
23
+
24
+
25
+ def web(mode: str, paths: list[Path]) -> str:
26
+ return subprocess.run([NODE, str(ROOT / "web/test/run.js"), mode, *map(str, paths)], check=True,
27
+ capture_output=True, text=True, env={"APRF_VERSION": __version__}).stdout
28
+
29
+
30
+ def same(paths: list[Path]) -> None:
31
+ loaded = load(paths)
32
+ assert json.loads(web("--json", paths)) == to_dict(loaded.reports, loaded.skipped)
33
+ assert web("--md", paths) == to_markdown(loaded)
34
+ assert web("--html", paths) == to_html(loaded)
35
+
36
+
37
+ def report(day: int, selector: str = "s1", inbox: int = 100) -> dict:
38
+ start = 1790812800 + day * 86400
39
+ return {"header": {"version": 1, "source": "Comcast/Xfinity", "dkim_domain": "Example.com",
40
+ "dkim_selector": selector, "report_start": start, "report_end": start + 86399,
41
+ "sdi_used": " - N/A - Beta - Not in use"},
42
+ "body": [{"classification": {"inbox": inbox, "unwanted": 0}, "engagement": {"positive": 0}}]}
43
+
44
+
45
+ def email(payload: bytes, subtype: str, filename: str | None, cte: str | None = None) -> EmailMessage:
46
+ msg = EmailMessage()
47
+ msg["From"] = "aprf-noreply@example.net"
48
+ msg["Subject"] = "APRF: test"
49
+ msg.set_content("Report attached.")
50
+ # cte=None would mean raw binary, not the base64 default
51
+ msg.add_attachment(payload, maintype="application", subtype=subtype, filename=filename, **({"cte": cte} if cte else {}))
52
+ return msg
53
+
54
+
55
+ @pytest.mark.parametrize("name", ["draft_sample_1.json", "draft_sample_2.json", "comcast_beta.json", "sample_six_weeks.json",
56
+ "stress_100.json"])
57
+ def test_fixtures(name):
58
+ same([FIX / name])
59
+
60
+
61
+ def test_all_fixtures_together():
62
+ same(sorted(FIX.glob("*.json")))
63
+
64
+
65
+ def test_files_emails_and_mailboxes(tmp_path):
66
+ def put(name: str, data: bytes) -> Path:
67
+ (tmp_path / name).write_bytes(data)
68
+ return tmp_path / name
69
+
70
+ raw = lambda day, **kw: json.dumps(report(day, **kw)).encode()
71
+ paths = [
72
+ put("a.json", raw(0)),
73
+ put("b.json.gz", gzip.compress(raw(1))),
74
+ put("c.eml", email(gzip.compress(raw(2)), "gzip", "20261003_example.com_s1_Comcast.json.gz").as_bytes()),
75
+ put("d.eml", email(raw(3), "octet-stream", None).as_bytes()),
76
+ put("e.eml", email(raw(4), "json", "r.json", cte="quoted-printable").as_bytes()),
77
+ put("copy.json", raw(0)),
78
+ put("resend.json", raw(0, inbox=900)),
79
+ put("note.eml", email(b"hello", "pdf", "invoice.pdf").as_bytes()),
80
+ ]
81
+ # A report email forwarded as an attachment
82
+ outer = EmailMessage()
83
+ outer["Subject"] = "Fwd: APRF"
84
+ outer.set_content("See attached.")
85
+ outer.add_attachment(email(raw(5, inbox=3000), "json", "f.json"))
86
+ paths.append(put("fwd.eml", outer.as_bytes()))
87
+ box = mailbox.mbox(tmp_path / "Reports.mbox")
88
+ for day in (6, 7, 9):
89
+ box.add(email(gzip.compress(raw(day, selector="s2")), "gzip", f"{day}.json.gz"))
90
+ box.add(email(raw(8, selector="s2"), "json", "same.json"))
91
+ plain = EmailMessage()
92
+ plain.set_content("From the team: not a report")
93
+ box.add(plain)
94
+ box.close()
95
+ paths.append(tmp_path / "Reports.mbox")
96
+ same(paths)
97
+
98
+
99
+ def test_hostile_text_escaped_the_same(tmp_path):
100
+ obj = report(0)
101
+ hostile = "<script>alert('x')</script>|a\\b \"q\" & é\nnew"
102
+ obj["header"].update(source=hostile, extra_info=hostile, sdi_used=hostile)
103
+ obj["body"][0]["segment"] = [hostile, "two"]
104
+ obj["body"][0]["classification"][hostile] = 7
105
+ (tmp_path / "h.json").write_text(json.dumps(obj))
106
+ same([tmp_path / "h.json"])
File without changes
File without changes