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.
- aprf_reader-0.1.0/LICENSE +21 -0
- aprf_reader-0.1.0/PKG-INFO +76 -0
- aprf_reader-0.1.0/README.md +64 -0
- aprf_reader-0.1.0/pyproject.toml +25 -0
- aprf_reader-0.1.0/setup.cfg +4 -0
- aprf_reader-0.1.0/src/aprf_reader/__init__.py +3 -0
- aprf_reader-0.1.0/src/aprf_reader/cli.py +43 -0
- aprf_reader-0.1.0/src/aprf_reader/load.py +98 -0
- aprf_reader-0.1.0/src/aprf_reader/render.py +126 -0
- aprf_reader-0.1.0/src/aprf_reader/report.py +141 -0
- aprf_reader-0.1.0/src/aprf_reader/summary.py +93 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/PKG-INFO +76 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/SOURCES.txt +16 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/dependency_links.txt +1 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/entry_points.txt +2 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/requires.txt +3 -0
- aprf_reader-0.1.0/src/aprf_reader.egg-info/top_level.txt +1 -0
- aprf_reader-0.1.0/tests/test_reader.py +139 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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 "<script>" 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
|