aprf-reader 0.5.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {aprf_reader-0.5.0/src/aprf_reader.egg-info → aprf_reader-0.6.1}/PKG-INFO +26 -9
  2. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/README.md +25 -8
  3. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/pyproject.toml +1 -1
  4. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader/__init__.py +1 -1
  5. aprf_reader-0.6.1/src/aprf_reader/cli.py +84 -0
  6. aprf_reader-0.6.1/src/aprf_reader/imap.py +85 -0
  7. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader/load.py +38 -2
  8. {aprf_reader-0.5.0 → aprf_reader-0.6.1/src/aprf_reader.egg-info}/PKG-INFO +26 -9
  9. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader.egg-info/SOURCES.txt +2 -0
  10. aprf_reader-0.6.1/tests/test_imap.py +118 -0
  11. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/tests/test_reader.py +5 -5
  12. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/tests/test_web_parity.py +15 -3
  13. aprf_reader-0.5.0/src/aprf_reader/cli.py +0 -55
  14. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/LICENSE +0 -0
  15. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/setup.cfg +0 -0
  16. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader/render.py +0 -0
  17. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader/report.py +0 -0
  18. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader/summary.py +0 -0
  19. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
  20. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader.egg-info/entry_points.txt +0 -0
  21. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader.egg-info/requires.txt +0 -0
  22. {aprf_reader-0.5.0 → aprf_reader-0.6.1}/src/aprf_reader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.5.0
3
+ Version: 0.6.1
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
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
- your reports on the page. They are read on your computer and never uploaded.
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.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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,
@@ -64,7 +65,21 @@ 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. Files it skips are listed
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 and checks the server's certificate; 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`, `.mbox` and `.zip`) or a folder of them, and skip
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. Nothing is uploaded anywhere.
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 blocks every network
156
- request, so it also works saved to disk. A merge to main that passes the
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
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
11
- your reports on the page. They are read on your computer and never uploaded.
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.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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,
@@ -51,7 +52,21 @@ 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. Files it skips are listed
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 and checks the server's certificate; 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`, `.mbox` and `.zip`) or a folder of them, and skip
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. Nothing is uploaded anywhere.
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 blocks every network
143
- request, so it also works saved to disk. A merge to main that passes the
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.5.0"
7
+ version = "0.6.1"
8
8
  description = "Summarise APRF (Aggregate Performance Reporting) inbox placement reports."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """Read APRF aggregate performance reports."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.1"
@@ -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,85 @@
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
+ import ssl
8
+ from collections.abc import Iterator
9
+ from datetime import date
10
+
11
+ # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
12
+ SUBJECT = "APRF:"
13
+ # Messages fetched per round trip
14
+ BATCH = 50
15
+ # A few years of daily reports for a few selectors; more is a wrong folder
16
+ MAX_MESSAGES = 20_000
17
+
18
+
19
+ class ImapError(Exception):
20
+ pass
21
+
22
+
23
+ def _check(typ: str, data: list, what: str) -> list:
24
+ if typ != "OK":
25
+ detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
26
+ raise ImapError(f"{what} failed: {detail or typ}")
27
+ return data
28
+
29
+
30
+ def _text(e: Exception) -> str:
31
+ # imaplib puts the server's reply bytes in the exception as they came
32
+ a = e.args[0] if e.args else ""
33
+ return a.decode(errors="replace") if isinstance(a, bytes) else str(e)
34
+
35
+
36
+ def _quote(s: str) -> str:
37
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
38
+
39
+
40
+ def criteria(since: date | None = None, every: bool = False) -> list[str]:
41
+ out = [] if every else ["SUBJECT", _quote(SUBJECT)]
42
+ if since:
43
+ out += ["SINCE", since.strftime("%d-%b-%Y")]
44
+ return out or ["ALL"]
45
+
46
+
47
+ def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
48
+ since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
49
+ """Yield (label, raw message bytes) for each matching message in one folder.
50
+
51
+ The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
52
+ message is marked read, moved or deleted."""
53
+ # imaplib's own default context checks neither the certificate nor the host name
54
+ m = conn or imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=60)
55
+ try:
56
+ try:
57
+ _check(*m.login(user, password), "login")
58
+ except imaplib.IMAP4.error as e:
59
+ raise ImapError(f"login failed: {_text(e)}") from None
60
+ try:
61
+ _check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
62
+ except imaplib.IMAP4.error as e:
63
+ raise ImapError(f"opening folder {folder} failed: {_text(e)}") from None
64
+ (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
65
+ uids = (ids or b"").split()
66
+ if len(uids) > MAX_MESSAGES:
67
+ raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
68
+ "pick the folder the reports are filed in, or use --since")
69
+ for i in range(0, len(uids), BATCH):
70
+ chunk = b",".join(uids[i:i + BATCH]).decode()
71
+ data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
72
+ for item in data:
73
+ if not isinstance(item, tuple):
74
+ continue # the closing ")" of each response
75
+ head, body = item
76
+ # Most servers put UID before the body; one that puts it after still has a sequence number
77
+ uid = re.search(rb"UID (\d+)", head)
78
+ which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
79
+ label = f"imap://{host}/{folder} {which}"
80
+ yield label, body
81
+ finally:
82
+ try:
83
+ m.logout()
84
+ except (imaplib.IMAP4.error, OSError):
85
+ pass
@@ -223,9 +223,25 @@ def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
223
223
  return out
224
224
 
225
225
 
226
- def load(paths: Iterable[Path]) -> Loaded:
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:
227
244
  out = Loaded()
228
- seen_bytes: set[str] = set()
229
245
  seen_keys: dict[tuple, tuple[str, Report]] = {}
230
246
 
231
247
  parsed: dict[str, list | ReportError] = {}
@@ -279,4 +295,24 @@ def load(paths: Iterable[Path]) -> Loaded:
279
295
  add(where, data, mail)
280
296
  except (OSError, EOFError, zlib.error, ReportError) as e:
281
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)))
282
318
  return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.5.0
3
+ Version: 0.6.1
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
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
- your reports on the page. They are read on your computer and never uploaded.
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.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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,
@@ -64,7 +65,21 @@ 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. Files it skips are listed
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 and checks the server's certificate; 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`, `.mbox` and `.zip`) or a folder of them, and skip
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. Nothing is uploaded anywhere.
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 blocks every network
156
- request, so it also works saved to disk. A merge to main that passes the
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,118 @@
1
+ import gzip
2
+ import imaplib
3
+ import json
4
+ import ssl
5
+ from datetime import date
6
+
7
+ import pytest
8
+
9
+ from aprf_reader import cli, imap
10
+ from aprf_reader.imap import ImapError, criteria, fetch
11
+ from aprf_reader.load import load
12
+
13
+ from test_reader import report, report_email
14
+
15
+
16
+ class FakeImap:
17
+ """Answers the calls fetch() makes the way imaplib returns a server's replies."""
18
+
19
+ def __init__(self, messages, password="right", folders=("INBOX", "APRF")):
20
+ self.messages = messages # {uid: raw bytes}
21
+ self.password, self.folders = password, folders
22
+ self.calls = []
23
+
24
+ def login(self, user, password):
25
+ if password != self.password:
26
+ raise imaplib.IMAP4.error("[AUTHENTICATIONFAILED] Invalid credentials")
27
+ return "OK", [b"logged in"]
28
+
29
+ def select(self, folder, readonly=False):
30
+ self.calls.append(("select", folder, readonly))
31
+ if folder.strip('"') not in self.folders:
32
+ return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
33
+ return "OK", [str(len(self.messages)).encode()]
34
+
35
+ def uid(self, command, *args):
36
+ self.calls.append((command, *args))
37
+ if command == "SEARCH":
38
+ return "OK", [b" ".join(str(u).encode() for u in self.messages)]
39
+ out = []
40
+ for u in args[0].split(","):
41
+ body = self.messages[int(u)]
42
+ out += [(f"{u} (UID {u} RFC822.SIZE {len(body)} BODY[] {{{len(body)}}}".encode(), body), b")"]
43
+ return "OK", out
44
+
45
+ def logout(self):
46
+ self.calls.append(("logout",))
47
+ return "BYE", [b""]
48
+
49
+
50
+ def mails(n=3):
51
+ out = {}
52
+ for d in range(n):
53
+ out[100 + d] = report_email(d, gzip.compress(json.dumps(report(d)).encode()), "gzip", f"{d}.json.gz").as_bytes()
54
+ return out
55
+
56
+
57
+ def test_fetch_is_read_only_and_batched(monkeypatch):
58
+ monkeypatch.setattr(imap, "BATCH", 2)
59
+ fake = FakeImap(mails(5))
60
+ got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
61
+ assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
62
+ assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
63
+ fetches = [c for c in fake.calls if c[0] == "FETCH"]
64
+ assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
65
+ assert fake.calls[-1] == ("logout",)
66
+
67
+
68
+ def test_criteria():
69
+ assert criteria() == ["SUBJECT", '"APRF:"']
70
+ assert criteria(date(2026, 10, 1)) == ["SUBJECT", '"APRF:"', "SINCE", "01-Oct-2026"]
71
+ assert criteria(every=True) == ["ALL"]
72
+
73
+
74
+ def test_login_and_folder_errors_are_plain():
75
+ with pytest.raises(ImapError, match="login failed: .*Invalid credentials"):
76
+ list(fetch("h", "u", "wrong", conn=FakeImap({})))
77
+ with pytest.raises(ImapError, match="opening folder Nope failed: .*Unknown Mailbox"):
78
+ list(fetch("h", "u", "right", "Nope", conn=FakeImap({})))
79
+
80
+
81
+ def test_fetched_mail_read_like_an_mbox():
82
+ msgs = list(mails(2).items())
83
+ note = b"Subject: APRF: hello\r\n\r\nno attachment\r\n"
84
+ loaded = load([], None, [(f"m{u}", b) for u, b in msgs] + [("m9", note), ("m10", note)], "imap://h/APRF")
85
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2]
86
+ assert loaded.skipped == [("imap://h/APRF", "2 messages with no report attached")]
87
+
88
+
89
+ def test_cli_imap(monkeypatch, capsys):
90
+ fake = FakeImap(mails(2))
91
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: fake)
92
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
93
+ assert cli.main(["--imap", "imap.example.com", "--user", "u", "--folder", "APRF", "--json"]) == 0
94
+ out, err = capsys.readouterr()
95
+ assert json.loads(out)["reports"] == 2
96
+ assert "read 2 messages from APRF on imap.example.com" in err
97
+
98
+
99
+ def test_cli_imap_failure_and_usage(monkeypatch, capsys):
100
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: FakeImap({}))
101
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "wrong")
102
+ assert cli.main(["--imap", "h", "--user", "u"]) == 2
103
+ assert "login failed" in capsys.readouterr().err
104
+ for argv in ([], ["--imap", "h"], ["--imap", "h:x", "--user", "u"]):
105
+ with pytest.raises(SystemExit):
106
+ cli.main(argv)
107
+
108
+
109
+ def test_certificate_is_checked(monkeypatch):
110
+ seen = {}
111
+
112
+ def connect(host, port, ssl_context, timeout):
113
+ seen["ctx"] = ssl_context
114
+ return FakeImap({})
115
+
116
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", connect)
117
+ list(fetch("h", "u", "right"))
118
+ assert seen["ctx"].verify_mode == ssl.CERT_REQUIRED and seen["ctx"].check_hostname
@@ -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"] # DNS-AUDIT.COM / SELECTOR1 is dns-audit.com / selector1
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", "dns-audit.com", "s11") in ss # another provider, same day: its own stream
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", "dns-audit.com", sel)].notes, sel
268
- blank = ss[("Comcast/Xfinity", "dns-audit.com", "s13")]
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", "dns-audit.com", "s8")].columns
270
+ assert not ss[("Comcast/Xfinity", "sender.example", "s8")].columns
271
271
  assert "These reports carry no counts." in to_markdown(loaded)
@@ -23,9 +23,10 @@ NODE = shutil.which("node")
23
23
  pytestmark = pytest.mark.skipif(NODE is None, reason="node is not installed")
24
24
 
25
25
 
26
- def web(mode: str, paths: list[Path]) -> str:
27
- return subprocess.run([NODE, str(ROOT / "web/test/run.js"), mode, *map(str, paths)], check=True,
28
- capture_output=True, text=True, env={"APRF_VERSION": __version__}).stdout
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()
29
30
 
30
31
 
31
32
  def _engine(doc: dict) -> dict:
@@ -168,3 +169,14 @@ def test_zips_read_the_same(tmp_path):
168
169
  assert reasons["inner.zip"] == reasons["box.mbox"] == "a zip or mailbox inside a zip is not read; unzip it first"
169
170
  assert reasons["huge.json"] == "larger than 10 MB" and reasons["fake.zip"] == "not a readable zip file"
170
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, .zip) 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