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.
- {aprf_reader-0.2.0/src/aprf_reader.egg-info → aprf_reader-0.3.1}/PKG-INFO +34 -5
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/README.md +33 -4
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/pyproject.toml +1 -1
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/cli.py +17 -5
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/load.py +9 -5
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/render.py +114 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/report.py +73 -14
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader/summary.py +47 -5
- {aprf_reader-0.2.0 → aprf_reader-0.3.1/src/aprf_reader.egg-info}/PKG-INFO +34 -5
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/SOURCES.txt +2 -1
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/tests/test_reader.py +68 -0
- aprf_reader-0.3.1/tests/test_web_parity.py +106 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/LICENSE +0 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/setup.cfg +0 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.2.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 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
|
-
|
|
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/ #
|
|
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
|
-
|
|
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/ #
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
46
|
+
args.md.write_text(text, encoding="utf-8")
|
|
35
47
|
else:
|
|
36
|
-
sys.stdout.write(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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((
|
|
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] =
|
|
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
|
|
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.
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
153
|
+
_day(end)
|
|
111
154
|
except (OverflowError, OSError, ValueError):
|
|
112
|
-
raise ReportError(f"header.report_end is out of range: {
|
|
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=
|
|
121
|
-
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
|
|
132
|
-
"""
|
|
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
|
-
|
|
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,
|
|
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[
|
|
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.
|
|
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
|
-
|
|
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/ #
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|