aprf-reader 0.1.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -17,7 +17,7 @@ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
17
17
  mail you signed landed: how much reached the inbox, how much was treated as
18
18
  unwanted, and how recipients reacted, day by day and per DKIM selector.
19
19
 
20
- **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
20
+ **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
21
 
22
22
  ## What APRF is
23
23
 
@@ -41,10 +41,16 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
41
41
  pipx install aprf-reader
42
42
  aprf-reader reports/ # Markdown summary to the terminal
43
43
  aprf-reader reports/ --html summary.html # and a page you can open or share
44
+ aprf-reader APRF.mbox # a mail folder exported as mbox
44
45
  ```
45
46
 
46
47
  Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
47
- or folders of them. Folders are searched recursively. Files it skips are listed
48
+ mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
49
+ is a mail filter that moves them into one folder, then an export of that folder:
50
+ Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
51
+ label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
52
+ Mailbox > Export Mailbox. Comcast's reports come from
53
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
48
54
  on stderr and at the end of the summary, each with a reason.
49
55
 
50
56
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -54,7 +60,7 @@ to mark a change in what a category counts.
54
60
 
55
61
  ## What it does
56
62
 
57
- - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
63
+ - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
58
64
  duplicates and files it cannot read, with a reason for each.
59
65
  - Summarise placement per signing domain and selector: inbox, unwanted and
60
66
  recipient reactions, by day and as a trend.
@@ -4,7 +4,7 @@ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
4
4
  mail you signed landed: how much reached the inbox, how much was treated as
5
5
  unwanted, and how recipients reacted, day by day and per DKIM selector.
6
6
 
7
- **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
7
+ **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
8
8
 
9
9
  ## What APRF is
10
10
 
@@ -28,10 +28,16 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
28
28
  pipx install aprf-reader
29
29
  aprf-reader reports/ # Markdown summary to the terminal
30
30
  aprf-reader reports/ --html summary.html # and a page you can open or share
31
+ aprf-reader APRF.mbox # a mail folder exported as mbox
31
32
  ```
32
33
 
33
34
  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
+ mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
36
+ is a mail filter that moves them into one folder, then an export of that folder:
37
+ Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
38
+ label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
39
+ Mailbox > Export Mailbox. Comcast's reports come from
40
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
35
41
  on stderr and at the end of the summary, each with a reason.
36
42
 
37
43
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -41,7 +47,7 @@ to mark a change in what a category counts.
41
47
 
42
48
  ## What it does
43
49
 
44
- - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
50
+ - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
45
51
  duplicates and files it cannot read, with a reason for each.
46
52
  - Summarise placement per signing domain and selector: inbox, unwanted and
47
53
  recipient reactions, by day and as a trend.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.1.1"
7
+ version = "0.2.0"
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.1.1"
3
+ __version__ = "0.2.0"
@@ -13,7 +13,7 @@ from .render import to_html, to_markdown
13
13
 
14
14
  def main(argv: list[str] | None = None) -> int:
15
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")
16
+ p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml, .mbox) or folders")
17
17
  p.add_argument("--md", type=Path, help="write the Markdown summary here instead of printing it")
18
18
  p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
19
19
  p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
@@ -0,0 +1,139 @@
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 mailbox
9
+ import zlib
10
+ from collections.abc import Iterable, Iterator
11
+ from dataclasses import dataclass, field
12
+ from email import policy
13
+ from email.message import Message
14
+ from email.parser import BytesParser
15
+ from pathlib import Path
16
+
17
+ from .report import Report, ReportError, parse
18
+
19
+ SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
20
+ # A daily report is a few KB; this stops a stray huge file or gzip bomb
21
+ MAX_BYTES = 10 * 1024 * 1024
22
+
23
+
24
+ @dataclass
25
+ class Loaded:
26
+ reports: list[Report] = field(default_factory=list)
27
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
28
+
29
+
30
+ def _files(paths: Iterable[Path]) -> Iterator[Path]:
31
+ for p in paths:
32
+ if p.is_dir():
33
+ yield from sorted(f for f in p.rglob("*") if f.is_file() and (f.name.lower().endswith(SUFFIXES) or _is_mbox(f)))
34
+ else:
35
+ yield p
36
+
37
+
38
+ def _is_mbox(path: Path) -> bool:
39
+ # Apple Mail exports a folder "Name.mbox" holding a file named just "mbox"
40
+ return path.name.lower() == "mbox" or path.name.lower().endswith(".mbox")
41
+
42
+
43
+ def _gunzip(data: bytes) -> bytes:
44
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
45
+ out = g.read(MAX_BYTES + 1)
46
+ if len(out) > MAX_BYTES:
47
+ raise ReportError("larger than 10 MB unpacked")
48
+ return out
49
+
50
+
51
+ def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes]]:
52
+ """Yield (label, report JSON bytes) for each report attached to one email."""
53
+ for part in msg.walk():
54
+ if part.is_multipart():
55
+ continue
56
+ fname = (part.get_filename() or "").lower()
57
+ ctype = part.get_content_type()
58
+ data = part.get_payload(decode=True) or b""
59
+ where = f"{label} ({fname or ctype})"
60
+ if len(data) > MAX_BYTES:
61
+ raise ReportError(f"{where} is larger than 10 MB")
62
+ if fname.endswith(".gz") or ctype in ("application/gzip", "application/x-gzip") or data[:2] == b"\x1f\x8b":
63
+ yield where, _gunzip(data)
64
+ # By name or type, or an attachment of unhelpful type that is a JSON array or object
65
+ elif fname.endswith(".json") or ctype == "application/json" or (
66
+ part.get_content_disposition() == "attachment" and data.lstrip()[:1] in (b"[", b"{")):
67
+ yield where, data
68
+
69
+
70
+ def _payloads(path: Path) -> Iterator[tuple[str, bytes]]:
71
+ """Yield (label, report JSON bytes) for each report in a file."""
72
+ data = path.read_bytes()
73
+ if len(data) > MAX_BYTES:
74
+ raise ReportError("larger than 10 MB")
75
+ if path.name.lower().endswith(".eml"):
76
+ found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), str(path)))
77
+ if not found:
78
+ raise ReportError("no report attachment")
79
+ yield from found
80
+ elif data[:2] == b"\x1f\x8b":
81
+ yield str(path), _gunzip(data)
82
+ else:
83
+ yield str(path), data
84
+
85
+
86
+ def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes]]:
87
+ """Yield reports from a mailbox export; one skip line for all mail with none attached."""
88
+ box = mailbox.mbox(path, factory=lambda f: BytesParser(policy=policy.default).parse(f), create=False)
89
+ without = 0
90
+ try:
91
+ for i, msg in enumerate(box, 1):
92
+ label = f"{path} message {i}"
93
+ try:
94
+ found = list(_attachments(msg, label))
95
+ # OSError covers gzip.BadGzipFile; one bad message must not end the mailbox
96
+ except (OSError, EOFError, zlib.error, ReportError) as e:
97
+ out.skipped.append((label, str(e)))
98
+ continue
99
+ without += not found
100
+ yield from found
101
+ finally:
102
+ box.close()
103
+ if without:
104
+ out.skipped.append((str(path), f"{without} message{'s' if without != 1 else ''} with no report attached"))
105
+
106
+
107
+ def load(paths: Iterable[Path]) -> Loaded:
108
+ out = Loaded()
109
+ seen_bytes: set[str] = set()
110
+ seen_keys: dict[tuple, str] = {}
111
+
112
+ def add(where: str, data: bytes) -> None:
113
+ digest = hashlib.sha256(data).hexdigest()
114
+ if digest in seen_bytes:
115
+ out.skipped.append((where, "duplicate of a file already read"))
116
+ return
117
+ seen_bytes.add(digest)
118
+ try:
119
+ reports = parse(data)
120
+ except ReportError as e:
121
+ out.skipped.append((where, str(e)))
122
+ return
123
+ for r in reports:
124
+ # The draft does not yet say whether a resend replaces the first; keep the first
125
+ if r.key in seen_keys:
126
+ out.skipped.append((where, f"same day already reported in {seen_keys[r.key]}; kept that one"))
127
+ continue
128
+ seen_keys[r.key] = where
129
+ out.reports.append(r)
130
+
131
+ for path in _files(paths):
132
+ try:
133
+ # An mbox can be large, so it is read one message at a time
134
+ payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
135
+ for where, data in payloads:
136
+ add(where, data)
137
+ except (OSError, EOFError, zlib.error, ReportError) as e:
138
+ out.skipped.append((str(path), str(e)))
139
+ return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -17,7 +17,7 @@ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
17
17
  mail you signed landed: how much reached the inbox, how much was treated as
18
18
  unwanted, and how recipients reacted, day by day and per DKIM selector.
19
19
 
20
- **Status: 0.1, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
20
+ **Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
21
21
 
22
22
  ## What APRF is
23
23
 
@@ -41,10 +41,16 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
41
41
  pipx install aprf-reader
42
42
  aprf-reader reports/ # Markdown summary to the terminal
43
43
  aprf-reader reports/ --html summary.html # and a page you can open or share
44
+ aprf-reader APRF.mbox # a mail folder exported as mbox
44
45
  ```
45
46
 
46
47
  Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
47
- or folders of them. Folders are searched recursively. Files it skips are listed
48
+ mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
49
+ is a mail filter that moves them into one folder, then an export of that folder:
50
+ Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
51
+ label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
52
+ Mailbox > Export Mailbox. Comcast's reports come from
53
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
48
54
  on stderr and at the end of the summary, each with a reason.
49
55
 
50
56
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -54,7 +60,7 @@ to mark a change in what a category counts.
54
60
 
55
61
  ## What it does
56
62
 
57
- - Read report files (`.json`, `.json.gz` and `.eml`) or a folder of them, and skip
63
+ - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
58
64
  duplicates and files it cannot read, with a reason for each.
59
65
  - Summarise placement per signing domain and selector: inbox, unwanted and
60
66
  recipient reactions, by day and as a trend.
@@ -1,5 +1,6 @@
1
1
  import gzip
2
2
  import json
3
+ import mailbox
3
4
  from datetime import date
4
5
  from email.message import EmailMessage
5
6
  from pathlib import Path
@@ -91,6 +92,45 @@ def test_load_gz_eml_folder_and_skips(tmp_path):
91
92
  assert "duplicate" in reasons["copy.json"] and "same day" in reasons["resend.json"]
92
93
 
93
94
 
95
+ def report_email(day: int, payload: bytes, subtype: str, filename: str | None) -> EmailMessage:
96
+ msg = EmailMessage()
97
+ msg["From"] = "aprf-noreply@alerts.xfinity.com"
98
+ msg["Subject"] = f"APRF: day {day}"
99
+ msg.set_content("report attached")
100
+ msg.add_attachment(payload, maintype="application", subtype=subtype, filename=filename)
101
+ return msg
102
+
103
+
104
+ def test_attachment_found_by_content(tmp_path):
105
+ # A provider may label the file octet-stream or give it no useful name
106
+ raw = json.dumps(report(0)).encode()
107
+ (tmp_path / "plain.eml").write_bytes(report_email(0, raw, "octet-stream", "20261001_example.com_s1_Comcast").as_bytes())
108
+ gz = gzip.compress(json.dumps(report(1)).encode())
109
+ (tmp_path / "gz.eml").write_bytes(report_email(1, gz, "octet-stream", None).as_bytes())
110
+ loaded = load([tmp_path])
111
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2] and not loaded.skipped
112
+
113
+
114
+ def test_mbox_export(tmp_path):
115
+ (tmp_path / "Reports.mbox").mkdir() # Apple Mail's export layout
116
+ box = mailbox.mbox(tmp_path / "Reports.mbox" / "mbox")
117
+ box.add(report_email(0, gzip.compress(json.dumps(report(0)).encode()), "gzip", "a.json.gz"))
118
+ box.add(report_email(1, json.dumps(report(1)).encode(), "json", "b.json"))
119
+ note = EmailMessage()
120
+ note["Subject"] = "not a report"
121
+ note.set_content("hello")
122
+ box.add(note)
123
+ box.add(note)
124
+ box.add(report_email(2, b"\x1f\x8b broken", "gzip", "c.json.gz"))
125
+ box.add(report_email(3, b"not gzip at all", "gzip", "d.json.gz"))
126
+ box.add(report_email(4, json.dumps(report(4)).encode(), "json", "e.json"))
127
+ box.close()
128
+ loaded = load([tmp_path])
129
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2, 5] # read on past bad messages
130
+ reasons = [r for _, r in loaded.skipped]
131
+ assert len(reasons) == 3 and "2 messages with no report attached" in reasons
132
+
133
+
94
134
  def test_hostile_files_skipped_not_crashed(tmp_path):
95
135
  (tmp_path / "deep.json").write_text("[" * 100_000 + "]" * 100_000)
96
136
  write(tmp_path / "far.json", report(0, report_end=10**20))
@@ -1,98 +0,0 @@
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
File without changes
File without changes