aprf-reader 0.1.0__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.
- {aprf_reader-0.1.0/src/aprf_reader.egg-info → aprf_reader-0.2.0}/PKG-INFO +11 -4
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/README.md +9 -3
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/pyproject.toml +2 -2
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader/cli.py +1 -1
- aprf_reader-0.2.0/src/aprf_reader/load.py +139 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader/render.py +2 -1
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader/report.py +3 -2
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader/summary.py +2 -1
- {aprf_reader-0.1.0 → aprf_reader-0.2.0/src/aprf_reader.egg-info}/PKG-INFO +11 -4
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader.egg-info/requires.txt +1 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/tests/test_reader.py +50 -0
- aprf_reader-0.1.0/src/aprf_reader/load.py +0 -98
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/LICENSE +0 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/setup.cfg +0 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.1.0 → aprf_reader-0.2.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.2.0
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Provides-Extra: dev
|
|
10
10
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
11
12
|
Dynamic: license-file
|
|
12
13
|
|
|
13
14
|
# aprf-reader
|
|
@@ -16,7 +17,7 @@ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
|
|
|
16
17
|
mail you signed landed: how much reached the inbox, how much was treated as
|
|
17
18
|
unwanted, and how recipients reacted, day by day and per DKIM selector.
|
|
18
19
|
|
|
19
|
-
**Status: 0.
|
|
20
|
+
**Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
20
21
|
|
|
21
22
|
## What APRF is
|
|
22
23
|
|
|
@@ -40,10 +41,16 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
|
|
|
40
41
|
pipx install aprf-reader
|
|
41
42
|
aprf-reader reports/ # Markdown summary to the terminal
|
|
42
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
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
46
|
-
or folders of them.
|
|
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
|
|
47
54
|
on stderr and at the end of the summary, each with a reason.
|
|
48
55
|
|
|
49
56
|
Each stream (provider, signing domain, selector) gets a table with one row per
|
|
@@ -53,7 +60,7 @@ to mark a change in what a category counts.
|
|
|
53
60
|
|
|
54
61
|
## What it does
|
|
55
62
|
|
|
56
|
-
- Read report files (`.json`, `.json.gz` and `.
|
|
63
|
+
- Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
|
|
57
64
|
duplicates and files it cannot read, with a reason for each.
|
|
58
65
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
59
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.
|
|
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.
|
|
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 `.
|
|
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.
|
|
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" }
|
|
@@ -12,7 +12,7 @@ requires-python = ">=3.11"
|
|
|
12
12
|
dependencies = []
|
|
13
13
|
|
|
14
14
|
[project.optional-dependencies]
|
|
15
|
-
dev = ["pytest>=7"]
|
|
15
|
+
dev = ["pytest>=7", "pytest-cov>=5"]
|
|
16
16
|
|
|
17
17
|
[project.scripts]
|
|
18
18
|
aprf-reader = "aprf_reader.cli:main"
|
|
@@ -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
|
|
@@ -34,7 +34,8 @@ def _counts(loaded: Loaded, ss: list[Stream]) -> str:
|
|
|
34
34
|
|
|
35
35
|
def _span(s: Stream) -> str:
|
|
36
36
|
gap = f", {_plural(s.missing, 'day')} with no report" if s.missing else ""
|
|
37
|
-
|
|
37
|
+
days = str(s.first) if s.first == s.last else f"{s.first} to {s.last}"
|
|
38
|
+
return f"{days}: {_plural(len(s.by_day), 'report')}{gap}"
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _day(row: Row) -> str:
|
|
@@ -15,7 +15,7 @@ DRAFT = "draft-ietf-mailmaint-aprf-00"
|
|
|
15
15
|
# Names the draft defines, in display order; providers may add others
|
|
16
16
|
PLACEMENT_NAMES = ("inbox", "unwanted")
|
|
17
17
|
ENGAGEMENT_NAMES = ("positive", "neutral", "negative")
|
|
18
|
-
# sdi_used values that mean no SDI was applied (section 5.2.1)
|
|
18
|
+
# sdi_used values (first word) that mean no SDI was applied (section 5.2.1)
|
|
19
19
|
NO_SDI = ("", "N/A", "N/F")
|
|
20
20
|
|
|
21
21
|
|
|
@@ -120,7 +120,8 @@ def _report(obj: object) -> Report:
|
|
|
120
120
|
report_start=h["report_start"],
|
|
121
121
|
report_end=h["report_end"],
|
|
122
122
|
version=_text(h.get("version")),
|
|
123
|
-
|
|
123
|
+
# Comcast's beta sends " - N/A - Beta - Not in use"
|
|
124
|
+
sdi_used=_text(h.get("sdi_used")).strip(" -"),
|
|
124
125
|
contact_info=_text(h.get("contact_info")),
|
|
125
126
|
extra_info=_text(h.get("extra_info")),
|
|
126
127
|
segments=tuple(_segment(s, i) for i, s in enumerate(body)),
|
|
@@ -80,7 +80,8 @@ class Stream:
|
|
|
80
80
|
prev = r.version
|
|
81
81
|
# "TBD" is the draft samples' placeholder
|
|
82
82
|
out += [f"Provider note: {t}" for t in sorted({r.extra_info for r in self.by_day.values()} - {"", "TBD"})]
|
|
83
|
-
sdi = sorted({r.sdi_used for r in self.by_day.values()
|
|
83
|
+
sdi = sorted({r.sdi_used for r in self.by_day.values()
|
|
84
|
+
if r.sdi_used.split(" ", 1)[0] not in NO_SDI})
|
|
84
85
|
if sdi:
|
|
85
86
|
out.append("SDI used: " + ", ".join(sdi))
|
|
86
87
|
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Provides-Extra: dev
|
|
10
10
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
11
12
|
Dynamic: license-file
|
|
12
13
|
|
|
13
14
|
# aprf-reader
|
|
@@ -16,7 +17,7 @@ Reads APRF (Aggregate Performance Reporting) reports and tells you where the
|
|
|
16
17
|
mail you signed landed: how much reached the inbox, how much was treated as
|
|
17
18
|
unwanted, and how recipients reacted, day by day and per DKIM selector.
|
|
18
19
|
|
|
19
|
-
**Status: 0.
|
|
20
|
+
**Status: 0.2, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
20
21
|
|
|
21
22
|
## What APRF is
|
|
22
23
|
|
|
@@ -40,10 +41,16 @@ environment, or with pip (`pip3` on some systems) inside a virtual environment.
|
|
|
40
41
|
pipx install aprf-reader
|
|
41
42
|
aprf-reader reports/ # Markdown summary to the terminal
|
|
42
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
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
46
|
-
or folders of them.
|
|
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
|
|
47
54
|
on stderr and at the end of the summary, each with a reason.
|
|
48
55
|
|
|
49
56
|
Each stream (provider, signing domain, selector) gets a table with one row per
|
|
@@ -53,7 +60,7 @@ to mark a change in what a category counts.
|
|
|
53
60
|
|
|
54
61
|
## What it does
|
|
55
62
|
|
|
56
|
-
- Read report files (`.json`, `.json.gz` and `.
|
|
63
|
+
- Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
|
|
57
64
|
duplicates and files it cannot read, with a reason for each.
|
|
58
65
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
59
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
|
|
@@ -39,6 +40,16 @@ def test_draft_samples_parse():
|
|
|
39
40
|
assert r2.segments[0].path == () and r2.sdi_used == "N/F"
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def test_comcast_beta_report(tmp_path, capsys):
|
|
44
|
+
# Shape of a real Comcast beta report as published by Spam Resource, July 2026; domain replaced
|
|
45
|
+
(r,) = parse((FIX / "comcast_beta.json").read_bytes())
|
|
46
|
+
assert r.version == "1" and r.sdi_used == "N/A - Beta - Not in use"
|
|
47
|
+
assert main([str(FIX / "comcast_beta.json")]) == 0
|
|
48
|
+
out = capsys.readouterr().out
|
|
49
|
+
assert "2026-07-13: 1 report" in out and " to 2026-07-13" not in out
|
|
50
|
+
assert "SDI used" not in out
|
|
51
|
+
|
|
52
|
+
|
|
42
53
|
def test_bare_object_and_string_segment():
|
|
43
54
|
obj = report(0)
|
|
44
55
|
obj["body"][0]["segment"] = "CustomerA"
|
|
@@ -81,6 +92,45 @@ def test_load_gz_eml_folder_and_skips(tmp_path):
|
|
|
81
92
|
assert "duplicate" in reasons["copy.json"] and "same day" in reasons["resend.json"]
|
|
82
93
|
|
|
83
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
|
+
|
|
84
134
|
def test_hostile_files_skipped_not_crashed(tmp_path):
|
|
85
135
|
(tmp_path / "deep.json").write_text("[" * 100_000 + "]" * 100_000)
|
|
86
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|