aprf-reader 0.4.1__tar.gz → 0.6.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.4.1/src/aprf_reader.egg-info → aprf_reader-0.6.0}/PKG-INFO +27 -10
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/README.md +26 -9
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/pyproject.toml +1 -1
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/__init__.py +1 -1
- aprf_reader-0.6.0/src/aprf_reader/cli.py +84 -0
- aprf_reader-0.6.0/src/aprf_reader/imap.py +77 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/load.py +88 -11
- {aprf_reader-0.4.1 → aprf_reader-0.6.0/src/aprf_reader.egg-info}/PKG-INFO +27 -10
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/SOURCES.txt +2 -0
- aprf_reader-0.6.0/tests/test_imap.py +105 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/tests/test_reader.py +5 -5
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/tests/test_web_parity.py +58 -6
- aprf_reader-0.4.1/src/aprf_reader/cli.py +0 -55
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/LICENSE +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/setup.cfg +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/render.py +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/report.py +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/summary.py +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.4.1 → aprf_reader-0.6.0}/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.6.0
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -20,11 +20,11 @@ points out the days worth a look, such as a jump in unwanted mail.
|
|
|
20
20
|
|
|
21
21
|
Two ways to use it, with the same results:
|
|
22
22
|
|
|
23
|
-
- **
|
|
24
|
-
your reports on the page.
|
|
23
|
+
- **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
|
|
24
|
+
paste your reports on the page.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -51,6 +51,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
|
|
|
51
51
|
aprf-reader reports/ --markdown # Markdown with daily tables
|
|
52
52
|
aprf-reader reports/ --html summary.html # and a page you can open or share
|
|
53
53
|
aprf-reader reports/ --json # the summary as data, for scripts
|
|
54
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
In a terminal you get an overview: for each stream, each count's latest value,
|
|
@@ -59,12 +60,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
59
60
|
is Markdown with the full daily tables.
|
|
60
61
|
|
|
61
62
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
62
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
63
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
63
64
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
64
65
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
65
66
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
66
67
|
Mailbox > Export Mailbox. Comcast's reports come from
|
|
67
|
-
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
68
|
+
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
69
|
+
|
|
70
|
+
Or skip the export and read the folder straight from your mailbox over IMAP:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
|
|
77
|
+
marked read, moved or deleted. It reads messages whose subject contains
|
|
78
|
+
`APRF:`; `--all-mail` reads every message in the folder. The password comes
|
|
79
|
+
from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
|
|
80
|
+
Outlook.com need an app password, not your account password, and a Gmail
|
|
81
|
+
label is read as a folder of the same name. The connection uses TLS on port
|
|
82
|
+
993; give another port as `--imap host:port`. Files it skips are listed
|
|
68
83
|
on stderr and at the end of the summary, each with a reason.
|
|
69
84
|
|
|
70
85
|
Each stream (provider, signing domain, selector) gets a table with one row per
|
|
@@ -78,14 +93,16 @@ to mark a change in what a category counts.
|
|
|
78
93
|
|
|
79
94
|
## What it does
|
|
80
95
|
|
|
81
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
96
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
|
|
97
|
+
mailbox folder over IMAP, and skip
|
|
82
98
|
duplicates and files it cannot read, with a reason for each.
|
|
83
99
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
84
100
|
recipient reactions, by day and as a trend.
|
|
85
101
|
- Treat the counts as what the draft says they are: rounded into buckets, so
|
|
86
102
|
they are a signal, not exact rates.
|
|
87
103
|
- Write a Markdown summary and a single self-contained HTML page you can open
|
|
88
|
-
or share.
|
|
104
|
+
or share. The command line tool sends nothing anywhere; with `--imap` it
|
|
105
|
+
only talks to the mail server you name.
|
|
89
106
|
- Follow the draft as it changes. The report format lives in one module
|
|
90
107
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
91
108
|
- Check each report against the draft and skip, with the reason, any it
|
|
@@ -152,8 +169,8 @@ repeats a report already read, such as the same report as `.json` and as
|
|
|
152
169
|
emails, forwarded emails, mailbox exports, duplicates and hostile text) and
|
|
153
170
|
requires the same JSON, Markdown and HTML output, so the page and the command
|
|
154
171
|
line cannot drift apart. `node web/build.js` inlines everything into one
|
|
155
|
-
`web/dist/index.html` whose Content Security Policy
|
|
156
|
-
|
|
172
|
+
`web/dist/index.html` whose Content Security Policy allows one connection, the
|
|
173
|
+
usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
|
|
157
174
|
tests deploys it to Cloudflare.
|
|
158
175
|
|
|
159
176
|
## Related
|
|
@@ -7,11 +7,11 @@ points out the days worth a look, such as a jump in unwanted mail.
|
|
|
7
7
|
|
|
8
8
|
Two ways to use it, with the same results:
|
|
9
9
|
|
|
10
|
-
- **
|
|
11
|
-
your reports on the page.
|
|
10
|
+
- **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
|
|
11
|
+
paste your reports on the page.
|
|
12
12
|
- **On the command line:** `pipx install aprf-reader`.
|
|
13
13
|
|
|
14
|
-
**Status: 0.
|
|
14
|
+
**Status: 0.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
15
15
|
|
|
16
16
|
## What APRF is
|
|
17
17
|
|
|
@@ -38,6 +38,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
|
|
|
38
38
|
aprf-reader reports/ --markdown # Markdown with daily tables
|
|
39
39
|
aprf-reader reports/ --html summary.html # and a page you can open or share
|
|
40
40
|
aprf-reader reports/ --json # the summary as data, for scripts
|
|
41
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
In a terminal you get an overview: for each stream, each count's latest value,
|
|
@@ -46,12 +47,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
46
47
|
is Markdown with the full daily tables.
|
|
47
48
|
|
|
48
49
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
49
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
50
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
50
51
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
51
52
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
52
53
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
53
54
|
Mailbox > Export Mailbox. Comcast's reports come from
|
|
54
|
-
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
55
|
+
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
56
|
+
|
|
57
|
+
Or skip the export and read the folder straight from your mailbox over IMAP:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
|
|
64
|
+
marked read, moved or deleted. It reads messages whose subject contains
|
|
65
|
+
`APRF:`; `--all-mail` reads every message in the folder. The password comes
|
|
66
|
+
from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
|
|
67
|
+
Outlook.com need an app password, not your account password, and a Gmail
|
|
68
|
+
label is read as a folder of the same name. The connection uses TLS on port
|
|
69
|
+
993; give another port as `--imap host:port`. Files it skips are listed
|
|
55
70
|
on stderr and at the end of the summary, each with a reason.
|
|
56
71
|
|
|
57
72
|
Each stream (provider, signing domain, selector) gets a table with one row per
|
|
@@ -65,14 +80,16 @@ to mark a change in what a category counts.
|
|
|
65
80
|
|
|
66
81
|
## What it does
|
|
67
82
|
|
|
68
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
83
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
|
|
84
|
+
mailbox folder over IMAP, and skip
|
|
69
85
|
duplicates and files it cannot read, with a reason for each.
|
|
70
86
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
71
87
|
recipient reactions, by day and as a trend.
|
|
72
88
|
- Treat the counts as what the draft says they are: rounded into buckets, so
|
|
73
89
|
they are a signal, not exact rates.
|
|
74
90
|
- Write a Markdown summary and a single self-contained HTML page you can open
|
|
75
|
-
or share.
|
|
91
|
+
or share. The command line tool sends nothing anywhere; with `--imap` it
|
|
92
|
+
only talks to the mail server you name.
|
|
76
93
|
- Follow the draft as it changes. The report format lives in one module
|
|
77
94
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
78
95
|
- Check each report against the draft and skip, with the reason, any it
|
|
@@ -139,8 +156,8 @@ repeats a report already read, such as the same report as `.json` and as
|
|
|
139
156
|
emails, forwarded emails, mailbox exports, duplicates and hostile text) and
|
|
140
157
|
requires the same JSON, Markdown and HTML output, so the page and the command
|
|
141
158
|
line cannot drift apart. `node web/build.js` inlines everything into one
|
|
142
|
-
`web/dist/index.html` whose Content Security Policy
|
|
143
|
-
|
|
159
|
+
`web/dist/index.html` whose Content Security Policy allows one connection, the
|
|
160
|
+
usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
|
|
144
161
|
tests deploys it to Cloudflare.
|
|
145
162
|
|
|
146
163
|
## Related
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command line entry point: aprf-reader <paths...>"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .imap import ImapError, fetch
|
|
16
|
+
from .load import load
|
|
17
|
+
from .render import to_html, to_markdown, to_terminal
|
|
18
|
+
from .summary import to_dict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
|
|
23
|
+
p.add_argument("paths", nargs="*", type=Path,
|
|
24
|
+
help="report files (.json, .json.gz, .eml, .mbox, .zip) or folders; - reads a pasted report or email from stdin")
|
|
25
|
+
p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
|
|
26
|
+
p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
|
|
27
|
+
p.add_argument("--markdown", action="store_true", help="print Markdown with daily tables, even in a terminal")
|
|
28
|
+
p.add_argument("--json", action="store_true", help="print the summary as JSON")
|
|
29
|
+
imap = p.add_argument_group("reading a mailbox over IMAP (read-only: nothing is marked read, moved or deleted)")
|
|
30
|
+
imap.add_argument("--imap", metavar="HOST[:PORT]", help="IMAP server, such as imap.gmail.com; TLS on port 993 unless given")
|
|
31
|
+
imap.add_argument("--user", help="IMAP login name; the password is read from APRF_IMAP_PASSWORD or asked for")
|
|
32
|
+
imap.add_argument("--folder", default="INBOX", help="folder to read (default INBOX)")
|
|
33
|
+
imap.add_argument("--since", type=date.fromisoformat, metavar="YYYY-MM-DD", help="only mail received on or after this day")
|
|
34
|
+
imap.add_argument("--all-mail", action="store_true", help='read every message in the folder, not only subjects with "APRF:"')
|
|
35
|
+
p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
|
|
36
|
+
args = p.parse_args(argv)
|
|
37
|
+
if not args.paths and not args.imap:
|
|
38
|
+
p.error("give report files or folders, or --imap")
|
|
39
|
+
if args.imap and not args.user:
|
|
40
|
+
p.error("--imap needs --user")
|
|
41
|
+
|
|
42
|
+
paths = [x for x in args.paths if str(x) != "-"]
|
|
43
|
+
for path in paths:
|
|
44
|
+
if not path.exists():
|
|
45
|
+
p.error(f"no such file or folder: {path}")
|
|
46
|
+
pasted = sys.stdin.buffer.read() if len(paths) < len(args.paths) else None
|
|
47
|
+
messages = []
|
|
48
|
+
if args.imap:
|
|
49
|
+
host, _, port = args.imap.partition(":")
|
|
50
|
+
if port and not port.isdigit():
|
|
51
|
+
p.error(f"--imap port is not a number: {port}")
|
|
52
|
+
password = os.environ.get("APRF_IMAP_PASSWORD") or getpass.getpass(f"IMAP password for {args.user}: ")
|
|
53
|
+
try:
|
|
54
|
+
# Fetched in full before reading, so a dropped connection leaves no half summary
|
|
55
|
+
messages = list(fetch(host, args.user, password, args.folder, int(port or 993), args.since, args.all_mail))
|
|
56
|
+
except (ImapError, OSError) as e:
|
|
57
|
+
print(f"aprf-reader: {args.imap}: {e}", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
print(f"read {len(messages)} message{'s' if len(messages) != 1 else ''} from {args.folder} on {host}", file=sys.stderr)
|
|
60
|
+
loaded = load(paths, pasted, messages, f"imap://{args.imap}/{args.folder}" if args.imap else "mailbox")
|
|
61
|
+
for where, reason in loaded.skipped:
|
|
62
|
+
print(f"skipped {where}: {reason}", file=sys.stderr)
|
|
63
|
+
if not loaded.reports:
|
|
64
|
+
print("no APRF reports found", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
if args.json:
|
|
68
|
+
text = json.dumps(to_dict(loaded), indent=2) + "\n"
|
|
69
|
+
elif args.md or args.markdown or not sys.stdout.isatty():
|
|
70
|
+
text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
|
|
71
|
+
else:
|
|
72
|
+
color = "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
|
|
73
|
+
text = to_terminal(loaded, color=color, width=shutil.get_terminal_size().columns)
|
|
74
|
+
if args.md:
|
|
75
|
+
args.md.write_text(text, encoding="utf-8")
|
|
76
|
+
else:
|
|
77
|
+
sys.stdout.write(text)
|
|
78
|
+
if args.html:
|
|
79
|
+
args.html.write_text(to_html(loaded), encoding="utf-8")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Read report emails straight from a mailbox over IMAP, without changing anything there."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import imaplib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
# Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
|
|
11
|
+
SUBJECT = "APRF:"
|
|
12
|
+
# Messages fetched per round trip
|
|
13
|
+
BATCH = 50
|
|
14
|
+
# A few years of daily reports for a few selectors; more is a wrong folder
|
|
15
|
+
MAX_MESSAGES = 20_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ImapError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check(typ: str, data: list, what: str) -> list:
|
|
23
|
+
if typ != "OK":
|
|
24
|
+
detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
|
|
25
|
+
raise ImapError(f"{what} failed: {detail or typ}")
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _quote(s: str) -> str:
|
|
30
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def criteria(since: date | None = None, every: bool = False) -> list[str]:
|
|
34
|
+
out = [] if every else ["SUBJECT", _quote(SUBJECT)]
|
|
35
|
+
if since:
|
|
36
|
+
out += ["SINCE", since.strftime("%d-%b-%Y")]
|
|
37
|
+
return out or ["ALL"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
|
|
41
|
+
since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
|
|
42
|
+
"""Yield (label, raw message bytes) for each matching message in one folder.
|
|
43
|
+
|
|
44
|
+
The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
|
|
45
|
+
message is marked read, moved or deleted."""
|
|
46
|
+
m = conn or imaplib.IMAP4_SSL(host, port, timeout=60)
|
|
47
|
+
try:
|
|
48
|
+
try:
|
|
49
|
+
_check(*m.login(user, password), "login")
|
|
50
|
+
except imaplib.IMAP4.error as e:
|
|
51
|
+
raise ImapError(f"login failed: {e}") from None
|
|
52
|
+
try:
|
|
53
|
+
_check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
|
|
54
|
+
except imaplib.IMAP4.error as e:
|
|
55
|
+
raise ImapError(f"opening folder {folder} failed: {e}") from None
|
|
56
|
+
(ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
|
|
57
|
+
uids = (ids or b"").split()
|
|
58
|
+
if len(uids) > MAX_MESSAGES:
|
|
59
|
+
raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
|
|
60
|
+
"pick the folder the reports are filed in, or use --since")
|
|
61
|
+
for i in range(0, len(uids), BATCH):
|
|
62
|
+
chunk = b",".join(uids[i:i + BATCH]).decode()
|
|
63
|
+
data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
|
|
64
|
+
for item in data:
|
|
65
|
+
if not isinstance(item, tuple):
|
|
66
|
+
continue # the closing ")" of each response
|
|
67
|
+
head, body = item
|
|
68
|
+
# Most servers put UID before the body; one that puts it after still has a sequence number
|
|
69
|
+
uid = re.search(rb"UID (\d+)", head)
|
|
70
|
+
which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
|
|
71
|
+
label = f"imap://{host}/{folder} {which}"
|
|
72
|
+
yield label, body
|
|
73
|
+
finally:
|
|
74
|
+
try:
|
|
75
|
+
m.logout()
|
|
76
|
+
except (imaplib.IMAP4.error, OSError):
|
|
77
|
+
pass
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import io
|
|
8
8
|
import mailbox
|
|
9
9
|
import re
|
|
10
|
+
import zipfile
|
|
10
11
|
import zlib
|
|
11
12
|
from collections.abc import Iterable, Iterator
|
|
12
13
|
from dataclasses import dataclass, field
|
|
@@ -17,7 +18,9 @@ from pathlib import Path
|
|
|
17
18
|
|
|
18
19
|
from .report import Report, ReportError, parse_items
|
|
19
20
|
|
|
20
|
-
SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
|
|
21
|
+
SUFFIXES = (".json", ".json.gz", ".eml", ".mbox", ".zip")
|
|
22
|
+
# A zip of a year of daily reports for a few selectors is a few thousand files
|
|
23
|
+
MAX_ZIP_ENTRIES = 20_000
|
|
21
24
|
# A daily report is a few KB; this stops a stray huge file or gzip bomb
|
|
22
25
|
MAX_BYTES = 10 * 1024 * 1024
|
|
23
26
|
|
|
@@ -97,20 +100,50 @@ def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes, Mail]]:
|
|
|
97
100
|
yield where, data, mail(False)
|
|
98
101
|
|
|
99
102
|
|
|
100
|
-
def _payloads(
|
|
101
|
-
"""Yield (label, report JSON bytes, how it arrived) for each report in
|
|
102
|
-
data = path.read_bytes()
|
|
103
|
+
def _payloads(label: str, data: bytes) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
104
|
+
"""Yield (label, report JSON bytes, how it arrived) for each report in one file's bytes."""
|
|
103
105
|
if len(data) > MAX_BYTES:
|
|
104
106
|
raise ReportError("larger than 10 MB")
|
|
105
|
-
if
|
|
106
|
-
found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data),
|
|
107
|
+
if label.lower().endswith(".eml"):
|
|
108
|
+
found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
|
|
107
109
|
if not found:
|
|
108
110
|
raise ReportError("no report attachment, which section 8 calls for")
|
|
109
111
|
yield from found
|
|
110
112
|
elif data[:2] == b"\x1f\x8b":
|
|
111
|
-
yield
|
|
113
|
+
yield label, _gunzip(data), None
|
|
112
114
|
else:
|
|
113
|
-
yield
|
|
115
|
+
yield label, data, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _zip(path: Path, out: Loaded) -> Iterator[tuple[str, bytes]]:
|
|
119
|
+
"""Yield (label, bytes) for each report file in a zip, in name order; a bad member is skipped, not fatal."""
|
|
120
|
+
try:
|
|
121
|
+
z = zipfile.ZipFile(path)
|
|
122
|
+
except zipfile.BadZipFile:
|
|
123
|
+
raise ReportError("not a readable zip file") from None
|
|
124
|
+
with z:
|
|
125
|
+
# __MACOSX holds Finder metadata copies named like the real files
|
|
126
|
+
members = sorted((i for i in z.infolist() if not i.is_dir() and not i.filename.startswith("__MACOSX/")
|
|
127
|
+
and i.filename.lower().endswith(SUFFIXES)), key=lambda i: i.filename)
|
|
128
|
+
if len(members) > MAX_ZIP_ENTRIES:
|
|
129
|
+
raise ReportError(f"more than {MAX_ZIP_ENTRIES:,} report files in one zip")
|
|
130
|
+
for i in members:
|
|
131
|
+
label = f"{path}/{i.filename}"
|
|
132
|
+
name = i.filename.lower()
|
|
133
|
+
if name.endswith((".zip", ".mbox")):
|
|
134
|
+
out.skipped.append((label, "a zip or mailbox inside a zip is not read; unzip it first"))
|
|
135
|
+
elif i.flag_bits & 1:
|
|
136
|
+
out.skipped.append((label, "encrypted in the zip"))
|
|
137
|
+
elif i.file_size > MAX_BYTES:
|
|
138
|
+
out.skipped.append((label, "larger than 10 MB"))
|
|
139
|
+
else:
|
|
140
|
+
try:
|
|
141
|
+
with z.open(i) as f:
|
|
142
|
+
data = f.read(MAX_BYTES + 1)
|
|
143
|
+
except (zipfile.BadZipFile, NotImplementedError, zlib.error, EOFError, OSError):
|
|
144
|
+
out.skipped.append((label, "could not be unpacked from the zip"))
|
|
145
|
+
continue
|
|
146
|
+
yield label, data
|
|
114
147
|
|
|
115
148
|
|
|
116
149
|
def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
@@ -190,9 +223,25 @@ def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
|
|
|
190
223
|
return out
|
|
191
224
|
|
|
192
225
|
|
|
193
|
-
def
|
|
226
|
+
def pasted_name(data: bytes) -> str:
|
|
227
|
+
"""A name for pasted text, so it is read like a file: a report's JSON, or a whole
|
|
228
|
+
email such as Gmail's Show original, attachment and all."""
|
|
229
|
+
head = data.lstrip()[:1]
|
|
230
|
+
if head in (b"[", b"{"):
|
|
231
|
+
return "pasted.json"
|
|
232
|
+
return "pasted.json.gz" if data[:2] == b"\x1f\x8b" else "pasted.eml"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _message(label: str, data: bytes) -> list[tuple[str, bytes, Mail]]:
|
|
236
|
+
"""Reports in one raw email fetched from a mailbox; mail with none is counted, not skipped."""
|
|
237
|
+
if len(data) > MAX_BYTES:
|
|
238
|
+
raise ReportError("larger than 10 MB")
|
|
239
|
+
return list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load(paths: Iterable[Path], pasted: bytes | None = None,
|
|
243
|
+
messages: Iterable[tuple[str, bytes]] = (), source: str = "mailbox") -> Loaded:
|
|
194
244
|
out = Loaded()
|
|
195
|
-
seen_bytes: set[str] = set()
|
|
196
245
|
seen_keys: dict[tuple, tuple[str, Report]] = {}
|
|
197
246
|
|
|
198
247
|
parsed: dict[str, list | ReportError] = {}
|
|
@@ -232,10 +281,38 @@ def load(paths: Iterable[Path]) -> Loaded:
|
|
|
232
281
|
|
|
233
282
|
for path in _files(paths):
|
|
234
283
|
try:
|
|
284
|
+
if path.name.lower().endswith(".zip"):
|
|
285
|
+
for label, data in _zip(path, out):
|
|
286
|
+
try:
|
|
287
|
+
for where, d, mail in list(_payloads(label, data)):
|
|
288
|
+
add(where, d, mail)
|
|
289
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
290
|
+
out.skipped.append((label, str(e)))
|
|
291
|
+
continue
|
|
235
292
|
# An mbox can be large, so it is read one message at a time
|
|
236
|
-
payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
|
|
293
|
+
payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(str(path), path.read_bytes()))
|
|
237
294
|
for where, data, mail in payloads:
|
|
238
295
|
add(where, data, mail)
|
|
239
296
|
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
240
297
|
out.skipped.append((str(path), str(e)))
|
|
298
|
+
# Raw emails from IMAP, read like an mbox: one line for all mail with no report
|
|
299
|
+
without = 0
|
|
300
|
+
for label, data in messages:
|
|
301
|
+
try:
|
|
302
|
+
found = _message(label, data)
|
|
303
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
304
|
+
out.skipped.append((label, str(e)))
|
|
305
|
+
continue
|
|
306
|
+
without += not found
|
|
307
|
+
for where, d, mail in found:
|
|
308
|
+
add(where, d, mail)
|
|
309
|
+
if without:
|
|
310
|
+
out.skipped.append((source, f"{without} message{'s' if without != 1 else ''} with no report attached"))
|
|
311
|
+
if pasted is not None:
|
|
312
|
+
name = pasted_name(pasted)
|
|
313
|
+
try:
|
|
314
|
+
for where, data, mail in list(_payloads(name, pasted)):
|
|
315
|
+
add(where, data, mail)
|
|
316
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
317
|
+
out.skipped.append((name, str(e)))
|
|
241
318
|
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -20,11 +20,11 @@ points out the days worth a look, such as a jump in unwanted mail.
|
|
|
20
20
|
|
|
21
21
|
Two ways to use it, with the same results:
|
|
22
22
|
|
|
23
|
-
- **
|
|
24
|
-
your reports on the page.
|
|
23
|
+
- **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
|
|
24
|
+
paste your reports on the page.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -51,6 +51,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
|
|
|
51
51
|
aprf-reader reports/ --markdown # Markdown with daily tables
|
|
52
52
|
aprf-reader reports/ --html summary.html # and a page you can open or share
|
|
53
53
|
aprf-reader reports/ --json # the summary as data, for scripts
|
|
54
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
In a terminal you get an overview: for each stream, each count's latest value,
|
|
@@ -59,12 +60,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
59
60
|
is Markdown with the full daily tables.
|
|
60
61
|
|
|
61
62
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
62
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
63
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
63
64
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
64
65
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
65
66
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
66
67
|
Mailbox > Export Mailbox. Comcast's reports come from
|
|
67
|
-
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
68
|
+
`aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
|
|
69
|
+
|
|
70
|
+
Or skip the export and read the folder straight from your mailbox over IMAP:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
|
|
77
|
+
marked read, moved or deleted. It reads messages whose subject contains
|
|
78
|
+
`APRF:`; `--all-mail` reads every message in the folder. The password comes
|
|
79
|
+
from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
|
|
80
|
+
Outlook.com need an app password, not your account password, and a Gmail
|
|
81
|
+
label is read as a folder of the same name. The connection uses TLS on port
|
|
82
|
+
993; give another port as `--imap host:port`. Files it skips are listed
|
|
68
83
|
on stderr and at the end of the summary, each with a reason.
|
|
69
84
|
|
|
70
85
|
Each stream (provider, signing domain, selector) gets a table with one row per
|
|
@@ -78,14 +93,16 @@ to mark a change in what a category counts.
|
|
|
78
93
|
|
|
79
94
|
## What it does
|
|
80
95
|
|
|
81
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
96
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
|
|
97
|
+
mailbox folder over IMAP, and skip
|
|
82
98
|
duplicates and files it cannot read, with a reason for each.
|
|
83
99
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
84
100
|
recipient reactions, by day and as a trend.
|
|
85
101
|
- Treat the counts as what the draft says they are: rounded into buckets, so
|
|
86
102
|
they are a signal, not exact rates.
|
|
87
103
|
- Write a Markdown summary and a single self-contained HTML page you can open
|
|
88
|
-
or share.
|
|
104
|
+
or share. The command line tool sends nothing anywhere; with `--imap` it
|
|
105
|
+
only talks to the mail server you name.
|
|
89
106
|
- Follow the draft as it changes. The report format lives in one module
|
|
90
107
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
91
108
|
- Check each report against the draft and skip, with the reason, any it
|
|
@@ -152,8 +169,8 @@ repeats a report already read, such as the same report as `.json` and as
|
|
|
152
169
|
emails, forwarded emails, mailbox exports, duplicates and hostile text) and
|
|
153
170
|
requires the same JSON, Markdown and HTML output, so the page and the command
|
|
154
171
|
line cannot drift apart. `node web/build.js` inlines everything into one
|
|
155
|
-
`web/dist/index.html` whose Content Security Policy
|
|
156
|
-
|
|
172
|
+
`web/dist/index.html` whose Content Security Policy allows one connection, the
|
|
173
|
+
usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
|
|
157
174
|
tests deploys it to Cloudflare.
|
|
158
175
|
|
|
159
176
|
## Related
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/aprf_reader/__init__.py
|
|
5
5
|
src/aprf_reader/cli.py
|
|
6
|
+
src/aprf_reader/imap.py
|
|
6
7
|
src/aprf_reader/load.py
|
|
7
8
|
src/aprf_reader/render.py
|
|
8
9
|
src/aprf_reader/report.py
|
|
@@ -13,5 +14,6 @@ src/aprf_reader.egg-info/dependency_links.txt
|
|
|
13
14
|
src/aprf_reader.egg-info/entry_points.txt
|
|
14
15
|
src/aprf_reader.egg-info/requires.txt
|
|
15
16
|
src/aprf_reader.egg-info/top_level.txt
|
|
17
|
+
tests/test_imap.py
|
|
16
18
|
tests/test_reader.py
|
|
17
19
|
tests/test_web_parity.py
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import imaplib
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from aprf_reader import cli, imap
|
|
9
|
+
from aprf_reader.imap import ImapError, criteria, fetch
|
|
10
|
+
from aprf_reader.load import load
|
|
11
|
+
|
|
12
|
+
from test_reader import report, report_email
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeImap:
|
|
16
|
+
"""Answers the calls fetch() makes the way imaplib returns a server's replies."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, messages, password="right", folders=("INBOX", "APRF")):
|
|
19
|
+
self.messages = messages # {uid: raw bytes}
|
|
20
|
+
self.password, self.folders = password, folders
|
|
21
|
+
self.calls = []
|
|
22
|
+
|
|
23
|
+
def login(self, user, password):
|
|
24
|
+
if password != self.password:
|
|
25
|
+
raise imaplib.IMAP4.error("[AUTHENTICATIONFAILED] Invalid credentials")
|
|
26
|
+
return "OK", [b"logged in"]
|
|
27
|
+
|
|
28
|
+
def select(self, folder, readonly=False):
|
|
29
|
+
self.calls.append(("select", folder, readonly))
|
|
30
|
+
if folder.strip('"') not in self.folders:
|
|
31
|
+
return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
|
|
32
|
+
return "OK", [str(len(self.messages)).encode()]
|
|
33
|
+
|
|
34
|
+
def uid(self, command, *args):
|
|
35
|
+
self.calls.append((command, *args))
|
|
36
|
+
if command == "SEARCH":
|
|
37
|
+
return "OK", [b" ".join(str(u).encode() for u in self.messages)]
|
|
38
|
+
out = []
|
|
39
|
+
for u in args[0].split(","):
|
|
40
|
+
body = self.messages[int(u)]
|
|
41
|
+
out += [(f"{u} (UID {u} RFC822.SIZE {len(body)} BODY[] {{{len(body)}}}".encode(), body), b")"]
|
|
42
|
+
return "OK", out
|
|
43
|
+
|
|
44
|
+
def logout(self):
|
|
45
|
+
self.calls.append(("logout",))
|
|
46
|
+
return "BYE", [b""]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mails(n=3):
|
|
50
|
+
out = {}
|
|
51
|
+
for d in range(n):
|
|
52
|
+
out[100 + d] = report_email(d, gzip.compress(json.dumps(report(d)).encode()), "gzip", f"{d}.json.gz").as_bytes()
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_fetch_is_read_only_and_batched(monkeypatch):
|
|
57
|
+
monkeypatch.setattr(imap, "BATCH", 2)
|
|
58
|
+
fake = FakeImap(mails(5))
|
|
59
|
+
got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
|
|
60
|
+
assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
|
|
61
|
+
assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
|
|
62
|
+
fetches = [c for c in fake.calls if c[0] == "FETCH"]
|
|
63
|
+
assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
|
|
64
|
+
assert fake.calls[-1] == ("logout",)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_criteria():
|
|
68
|
+
assert criteria() == ["SUBJECT", '"APRF:"']
|
|
69
|
+
assert criteria(date(2026, 10, 1)) == ["SUBJECT", '"APRF:"', "SINCE", "01-Oct-2026"]
|
|
70
|
+
assert criteria(every=True) == ["ALL"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_login_and_folder_errors_are_plain():
|
|
74
|
+
with pytest.raises(ImapError, match="login failed: .*Invalid credentials"):
|
|
75
|
+
list(fetch("h", "u", "wrong", conn=FakeImap({})))
|
|
76
|
+
with pytest.raises(ImapError, match="opening folder Nope failed: .*Unknown Mailbox"):
|
|
77
|
+
list(fetch("h", "u", "right", "Nope", conn=FakeImap({})))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_fetched_mail_read_like_an_mbox():
|
|
81
|
+
msgs = list(mails(2).items())
|
|
82
|
+
note = b"Subject: APRF: hello\r\n\r\nno attachment\r\n"
|
|
83
|
+
loaded = load([], None, [(f"m{u}", b) for u, b in msgs] + [("m9", note), ("m10", note)], "imap://h/APRF")
|
|
84
|
+
assert sorted(r.day.day for r in loaded.reports) == [1, 2]
|
|
85
|
+
assert loaded.skipped == [("imap://h/APRF", "2 messages with no report attached")]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_cli_imap(monkeypatch, capsys):
|
|
89
|
+
fake = FakeImap(mails(2))
|
|
90
|
+
monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: fake)
|
|
91
|
+
monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
|
|
92
|
+
assert cli.main(["--imap", "imap.example.com", "--user", "u", "--folder", "APRF", "--json"]) == 0
|
|
93
|
+
out, err = capsys.readouterr()
|
|
94
|
+
assert json.loads(out)["reports"] == 2
|
|
95
|
+
assert "read 2 messages from APRF on imap.example.com" in err
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_cli_imap_failure_and_usage(monkeypatch, capsys):
|
|
99
|
+
monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: FakeImap({}))
|
|
100
|
+
monkeypatch.setenv("APRF_IMAP_PASSWORD", "wrong")
|
|
101
|
+
assert cli.main(["--imap", "h", "--user", "u"]) == 2
|
|
102
|
+
assert "login failed" in capsys.readouterr().err
|
|
103
|
+
for argv in ([], ["--imap", "h"], ["--imap", "h:x", "--user", "u"]):
|
|
104
|
+
with pytest.raises(SystemExit):
|
|
105
|
+
cli.main(argv)
|
|
@@ -249,7 +249,7 @@ def test_stress_set_one_bad_report_does_not_cost_the_file():
|
|
|
249
249
|
dup = {where.rsplit(" ", 1)[-1]: d for w, d, where in loaded.warnings if w.startswith("the same day came twice")}
|
|
250
250
|
assert dup["64"] == f"{FIX}/stress_100.json report 63 kept, version 1 kept, version 2 dropped"
|
|
251
251
|
assert dup["69"].endswith("report 68 kept, with different counts") # bücher.example
|
|
252
|
-
assert "report 3 kept" in dup["70"] #
|
|
252
|
+
assert "report 3 kept" in dup["70"] # SENDER.EXAMPLE / SELECTOR1 is sender.example / selector1
|
|
253
253
|
assert not {"64", "69", "70"} & set(reasons)
|
|
254
254
|
warned = {w for w, _, _ in loaded.warnings}
|
|
255
255
|
assert "sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)" in warned
|
|
@@ -259,13 +259,13 @@ def test_stress_set_one_bad_report_does_not_cost_the_file():
|
|
|
259
259
|
assert reasons["79"] == "header.report_end is before report_start (section 5.1)"
|
|
260
260
|
assert reasons["81"].startswith("header.report_start looks like milliseconds")
|
|
261
261
|
ss = {(s.source, s.domain, s.selector): s for s in streams(loaded.reports)}
|
|
262
|
-
assert ("Big MBP Co", "
|
|
262
|
+
assert ("Big MBP Co", "sender.example", "s11") in ss # another provider, same day: its own stream
|
|
263
263
|
obj = report(0)
|
|
264
264
|
obj["body"][0]["classification"]["unwanted"] = 100.0
|
|
265
265
|
assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
|
|
266
266
|
for sel in ("sdi0", "sdi1", "sdi2", "sdi3", "sdi4"): # n/a, "N/A ", "", null, N/F;
|
|
267
|
-
assert not ss[("Comcast/Xfinity", "
|
|
268
|
-
blank = ss[("Comcast/Xfinity", "
|
|
267
|
+
assert not ss[("Comcast/Xfinity", "sender.example", sel)].notes, sel
|
|
268
|
+
blank = ss[("Comcast/Xfinity", "sender.example", "s13")]
|
|
269
269
|
assert "(blank)" in [r.segment for r in blank.rows]
|
|
270
|
-
assert not ss[("Comcast/Xfinity", "
|
|
270
|
+
assert not ss[("Comcast/Xfinity", "sender.example", "s8")].columns
|
|
271
271
|
assert "These reports carry no counts." in to_markdown(loaded)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import gzip
|
|
4
4
|
import json
|
|
5
5
|
import mailbox
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
from email.message import EmailMessage
|
|
@@ -22,16 +23,24 @@ NODE = shutil.which("node")
|
|
|
22
23
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node is not installed")
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def web(mode: str, paths: list
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
def web(mode: str, paths: list, paste: bytes | None = None) -> str:
|
|
27
|
+
args = [*map(str, paths)] + (["-"] if paste is not None else [])
|
|
28
|
+
return subprocess.run([NODE, str(ROOT / "web/test/run.js"), mode, *args], check=True, input=paste or b"",
|
|
29
|
+
capture_output=True, env={"APRF_VERSION": __version__}).stdout.decode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _engine(doc: dict) -> dict:
|
|
33
|
+
"""Python and browsers word JSON parse errors differently; compare only our part."""
|
|
34
|
+
cut = lambda r: r.split(": ", 1)[0] if r.startswith("not valid JSON") else r
|
|
35
|
+
return {**doc, "skipped": [{**k, "reason": cut(k["reason"])} for k in doc["skipped"]]}
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
def same(paths: list[Path]) -> None:
|
|
31
39
|
loaded = load(paths)
|
|
32
|
-
assert json.loads(web("--json", paths)) == to_dict(loaded)
|
|
33
|
-
|
|
34
|
-
assert web("--
|
|
40
|
+
assert _engine(json.loads(web("--json", paths))) == _engine(to_dict(loaded))
|
|
41
|
+
text = lambda t: re.sub(r"(not valid JSON, which section 5 requires): [^\n<]*", r"\1", t)
|
|
42
|
+
assert text(web("--md", paths)) == text(to_markdown(loaded))
|
|
43
|
+
assert text(web("--html", paths)) == text(to_html(loaded))
|
|
35
44
|
|
|
36
45
|
|
|
37
46
|
def report(day: int, selector: str = "s1", inbox: int = 100) -> dict:
|
|
@@ -128,3 +137,46 @@ def test_section_8_checks_the_same(tmp_path):
|
|
|
128
137
|
assert "attachment name stands in for a character a file name cannot hold, such as / or *" in by_file["safe.eml"]
|
|
129
138
|
assert {"attachment name does not follow section 8", "subject does not follow section 8",
|
|
130
139
|
"attachment Content-Type is not application/json, or application/gzip when compressed"} <= by_file["wrong.eml"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_zips_read_the_same(tmp_path):
|
|
143
|
+
import zipfile
|
|
144
|
+
raw = lambda day, **kw: json.dumps(report(day, **kw)).encode()
|
|
145
|
+
z = tmp_path / "reports.zip"
|
|
146
|
+
with zipfile.ZipFile(z, "w") as f:
|
|
147
|
+
f.writestr("b/20261002.json", raw(1), compress_type=zipfile.ZIP_DEFLATED)
|
|
148
|
+
f.writestr("a/20261001.json", raw(0), compress_type=zipfile.ZIP_STORED)
|
|
149
|
+
f.writestr("c/20261003.json.gz", gzip.compress(raw(2)))
|
|
150
|
+
f.writestr("d/20261004.eml", email(raw(3), "json", "x.json").as_bytes(), compress_type=zipfile.ZIP_DEFLATED)
|
|
151
|
+
f.writestr("__MACOSX/a/._20261001.json", b"\x00\x05\x16\x07 Finder junk")
|
|
152
|
+
f.writestr("e/inner.zip", b"PK nested")
|
|
153
|
+
f.writestr("e/box.mbox", b"From x\n\nhi\n")
|
|
154
|
+
f.writestr("f/huge.json", b" " * (11 * 1024 * 1024), compress_type=zipfile.ZIP_DEFLATED)
|
|
155
|
+
f.writestr("g/notes.txt", b"not a report suffix")
|
|
156
|
+
f.writestr("h/broken.json", b"{oops", compress_type=zipfile.ZIP_DEFLATED)
|
|
157
|
+
f.writestr("i/crc.json", raw(5), compress_type=zipfile.ZIP_STORED)
|
|
158
|
+
# Flip one byte of the stored crc.json so its CRC no longer matches
|
|
159
|
+
data = bytearray(z.read_bytes())
|
|
160
|
+
at = data.index(b'"dkim_selector"', data.index(b"i/crc.json") + 10)
|
|
161
|
+
data[at + 2] ^= 0x01
|
|
162
|
+
z.write_bytes(bytes(data))
|
|
163
|
+
(tmp_path / "fake.zip").write_bytes(b"not a zip at all")
|
|
164
|
+
same([z, tmp_path / "fake.zip"])
|
|
165
|
+
loaded = load([z, tmp_path / "fake.zip"])
|
|
166
|
+
assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3, 4]
|
|
167
|
+
reasons = {w.rsplit("/", 1)[-1]: r for w, r in loaded.skipped}
|
|
168
|
+
assert reasons["crc.json"] == "could not be unpacked from the zip"
|
|
169
|
+
assert reasons["inner.zip"] == reasons["box.mbox"] == "a zip or mailbox inside a zip is not read; unzip it first"
|
|
170
|
+
assert reasons["huge.json"] == "larger than 10 MB" and reasons["fake.zip"] == "not a readable zip file"
|
|
171
|
+
assert "broken.json" in reasons and not any("MACOSX" in w for w, _ in loaded.skipped)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize("kind", ["json", "email", "gzip", "junk"])
|
|
175
|
+
def test_pastes_read_the_same(kind):
|
|
176
|
+
raw = json.dumps(report(0)).encode()
|
|
177
|
+
paste = {"json": b" \n" + raw, "email": email(gzip.compress(raw), "gzip", "r.json.gz").as_bytes(),
|
|
178
|
+
"gzip": gzip.compress(raw), "junk": b"hello, not a report"}[kind]
|
|
179
|
+
loaded = load([], paste)
|
|
180
|
+
assert _engine(json.loads(web("--json", [], paste))) == _engine(to_dict(loaded))
|
|
181
|
+
assert (len(loaded.reports), loaded.skipped[0][0] if loaded.skipped else None) == (
|
|
182
|
+
{"json": (1, None), "email": (1, None), "gzip": (1, None), "junk": (0, "pasted.eml")}[kind])
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Command line entry point: aprf-reader <paths...>"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import shutil
|
|
9
|
-
import sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from . import __version__
|
|
13
|
-
from .load import load
|
|
14
|
-
from .render import to_html, to_markdown, to_terminal
|
|
15
|
-
from .summary import to_dict
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def main(argv: list[str] | None = None) -> int:
|
|
19
|
-
p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
|
|
20
|
-
p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml, .mbox) or folders")
|
|
21
|
-
p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
|
|
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")
|
|
25
|
-
p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
|
|
26
|
-
args = p.parse_args(argv)
|
|
27
|
-
|
|
28
|
-
for path in args.paths:
|
|
29
|
-
if not path.exists():
|
|
30
|
-
p.error(f"no such file or folder: {path}")
|
|
31
|
-
loaded = load(args.paths)
|
|
32
|
-
for where, reason in loaded.skipped:
|
|
33
|
-
print(f"skipped {where}: {reason}", file=sys.stderr)
|
|
34
|
-
if not loaded.reports:
|
|
35
|
-
print("no APRF reports found", file=sys.stderr)
|
|
36
|
-
return 1
|
|
37
|
-
|
|
38
|
-
if args.json:
|
|
39
|
-
text = json.dumps(to_dict(loaded), 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)
|
|
45
|
-
if args.md:
|
|
46
|
-
args.md.write_text(text, encoding="utf-8")
|
|
47
|
-
else:
|
|
48
|
-
sys.stdout.write(text)
|
|
49
|
-
if args.html:
|
|
50
|
-
args.html.write_text(to_html(loaded), encoding="utf-8")
|
|
51
|
-
return 0
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if __name__ == "__main__":
|
|
55
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|