aprf-reader 0.3.1__tar.gz → 0.4.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.
- {aprf_reader-0.3.1/src/aprf_reader.egg-info → aprf_reader-0.4.1}/PKG-INFO +58 -4
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/README.md +57 -3
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/pyproject.toml +1 -1
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader/cli.py +1 -1
- aprf_reader-0.4.1/src/aprf_reader/load.py +241 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader/render.py +25 -1
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader/report.py +58 -17
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader/summary.py +20 -1
- {aprf_reader-0.3.1 → aprf_reader-0.4.1/src/aprf_reader.egg-info}/PKG-INFO +58 -4
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/tests/test_reader.py +22 -8
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/tests/test_web_parity.py +25 -1
- aprf_reader-0.3.1/src/aprf_reader/load.py +0 -143
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/LICENSE +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/setup.cfg +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.1}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.3.1 → aprf_reader-0.4.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.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -24,7 +24,7 @@ Two ways to use it, with the same results:
|
|
|
24
24
|
your reports on the page. They are read on your computer and never uploaded.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -88,8 +88,62 @@ to mark a change in what a category counts.
|
|
|
88
88
|
or share. Nothing is uploaded anywhere.
|
|
89
89
|
- Follow the draft as it changes. The report format lives in one module
|
|
90
90
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
91
|
-
-
|
|
92
|
-
|
|
91
|
+
- Check each report against the draft and skip, with the reason, any it
|
|
92
|
+
cannot trust. One bad report in a file does not cost the rest of the file.
|
|
93
|
+
|
|
94
|
+
## Checking against the draft
|
|
95
|
+
|
|
96
|
+
Every report is checked against draft-ietf-mailmaint-aprf-00, and every
|
|
97
|
+
message names the section, so a list of them can go straight into a review of
|
|
98
|
+
the draft. On the web page, Copy for a draft review copies them as text.
|
|
99
|
+
|
|
100
|
+
**Skipped**, because the report cannot be trusted:
|
|
101
|
+
|
|
102
|
+
| Problem | Section |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Not valid JSON | 5 |
|
|
105
|
+
| A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
|
|
106
|
+
| Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
|
|
107
|
+
| Body not a list of segments; a segment not an object | 5.2.2 |
|
|
108
|
+
| A placement or engagement count that is not a whole number | 6, 7 |
|
|
109
|
+
| A saved email with no report attached | 8 |
|
|
110
|
+
|
|
111
|
+
**Warnings**, where the report is still read:
|
|
112
|
+
|
|
113
|
+
| Warning | Section |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
|
|
116
|
+
| Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
|
|
117
|
+
| Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
|
|
118
|
+
| One attachment holding several reports | 8 |
|
|
119
|
+
| A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
|
|
120
|
+
| A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
|
|
121
|
+
| The same day twice from one provider | left open, note in 8.1 |
|
|
122
|
+
|
|
123
|
+
Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
|
|
124
|
+
of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
|
|
125
|
+
correct file name for them cannot follow section 8 exactly. A file that
|
|
126
|
+
repeats a report already read, such as the same report as `.json` and as
|
|
127
|
+
`.eml`, is counted once and summed up in one line.
|
|
128
|
+
|
|
129
|
+
## How it decides
|
|
130
|
+
|
|
131
|
+
- **The same day twice, from the same provider:** it keeps the first copy it
|
|
132
|
+
reads and skips the other, saying whether the skipped copy had a different
|
|
133
|
+
`version` or different counts. The draft leaves resends open (a note in
|
|
134
|
+
section 8.1 asks whether a later report should overwrite or be discarded),
|
|
135
|
+
and `version` is a free string with no order, so there is no defined way
|
|
136
|
+
to pick the newer one. Files are read in name order, so the first copy is
|
|
137
|
+
the first by file name, then by position in the file.
|
|
138
|
+
- **The same day from two providers:** both are kept, as separate streams.
|
|
139
|
+
- **Names:** signing domains and selectors compare without case, and an
|
|
140
|
+
international domain matches its `xn--` form, so `bücher.example` and
|
|
141
|
+
`xn--bcher-kva.example` are one stream.
|
|
142
|
+
- **The reporting window:** section 5.1 says a report MUST cover one UTC day,
|
|
143
|
+
from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
|
|
144
|
+
day boundary, is skipped with a reason that names section 5.1.
|
|
145
|
+
- **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
|
|
146
|
+
numbers are refused.
|
|
93
147
|
|
|
94
148
|
## How the web page is built
|
|
95
149
|
|
|
@@ -11,7 +11,7 @@ Two ways to use it, with the same results:
|
|
|
11
11
|
your reports on the page. They are read on your computer and never uploaded.
|
|
12
12
|
- **On the command line:** `pipx install aprf-reader`.
|
|
13
13
|
|
|
14
|
-
**Status: 0.
|
|
14
|
+
**Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
15
15
|
|
|
16
16
|
## What APRF is
|
|
17
17
|
|
|
@@ -75,8 +75,62 @@ to mark a change in what a category counts.
|
|
|
75
75
|
or share. Nothing is uploaded anywhere.
|
|
76
76
|
- Follow the draft as it changes. The report format lives in one module
|
|
77
77
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
78
|
-
-
|
|
79
|
-
|
|
78
|
+
- Check each report against the draft and skip, with the reason, any it
|
|
79
|
+
cannot trust. One bad report in a file does not cost the rest of the file.
|
|
80
|
+
|
|
81
|
+
## Checking against the draft
|
|
82
|
+
|
|
83
|
+
Every report is checked against draft-ietf-mailmaint-aprf-00, and every
|
|
84
|
+
message names the section, so a list of them can go straight into a review of
|
|
85
|
+
the draft. On the web page, Copy for a draft review copies them as text.
|
|
86
|
+
|
|
87
|
+
**Skipped**, because the report cannot be trusted:
|
|
88
|
+
|
|
89
|
+
| Problem | Section |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Not valid JSON | 5 |
|
|
92
|
+
| A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
|
|
93
|
+
| Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
|
|
94
|
+
| Body not a list of segments; a segment not an object | 5.2.2 |
|
|
95
|
+
| A placement or engagement count that is not a whole number | 6, 7 |
|
|
96
|
+
| A saved email with no report attached | 8 |
|
|
97
|
+
|
|
98
|
+
**Warnings**, where the report is still read:
|
|
99
|
+
|
|
100
|
+
| Warning | Section |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
|
|
103
|
+
| Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
|
|
104
|
+
| Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
|
|
105
|
+
| One attachment holding several reports | 8 |
|
|
106
|
+
| A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
|
|
107
|
+
| A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
|
|
108
|
+
| The same day twice from one provider | left open, note in 8.1 |
|
|
109
|
+
|
|
110
|
+
Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
|
|
111
|
+
of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
|
|
112
|
+
correct file name for them cannot follow section 8 exactly. A file that
|
|
113
|
+
repeats a report already read, such as the same report as `.json` and as
|
|
114
|
+
`.eml`, is counted once and summed up in one line.
|
|
115
|
+
|
|
116
|
+
## How it decides
|
|
117
|
+
|
|
118
|
+
- **The same day twice, from the same provider:** it keeps the first copy it
|
|
119
|
+
reads and skips the other, saying whether the skipped copy had a different
|
|
120
|
+
`version` or different counts. The draft leaves resends open (a note in
|
|
121
|
+
section 8.1 asks whether a later report should overwrite or be discarded),
|
|
122
|
+
and `version` is a free string with no order, so there is no defined way
|
|
123
|
+
to pick the newer one. Files are read in name order, so the first copy is
|
|
124
|
+
the first by file name, then by position in the file.
|
|
125
|
+
- **The same day from two providers:** both are kept, as separate streams.
|
|
126
|
+
- **Names:** signing domains and selectors compare without case, and an
|
|
127
|
+
international domain matches its `xn--` form, so `bücher.example` and
|
|
128
|
+
`xn--bcher-kva.example` are one stream.
|
|
129
|
+
- **The reporting window:** section 5.1 says a report MUST cover one UTC day,
|
|
130
|
+
from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
|
|
131
|
+
day boundary, is skipped with a reason that names section 5.1.
|
|
132
|
+
- **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
|
|
133
|
+
numbers are refused.
|
|
80
134
|
|
|
81
135
|
## How the web page is built
|
|
82
136
|
|
|
@@ -36,7 +36,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
36
36
|
return 1
|
|
37
37
|
|
|
38
38
|
if args.json:
|
|
39
|
-
text = json.dumps(to_dict(loaded
|
|
39
|
+
text = json.dumps(to_dict(loaded), indent=2) + "\n"
|
|
40
40
|
elif args.md or args.markdown or not sys.stdout.isatty():
|
|
41
41
|
text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
|
|
42
42
|
else:
|
|
@@ -0,0 +1,241 @@
|
|
|
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 re
|
|
10
|
+
import zlib
|
|
11
|
+
from collections.abc import Iterable, Iterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from email import policy
|
|
14
|
+
from email.message import Message
|
|
15
|
+
from email.parser import BytesParser
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .report import Report, ReportError, parse_items
|
|
19
|
+
|
|
20
|
+
SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
|
|
21
|
+
# A daily report is a few KB; this stops a stray huge file or gzip bomb
|
|
22
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Loaded:
|
|
27
|
+
reports: list[Report] = field(default_factory=list)
|
|
28
|
+
skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
|
|
29
|
+
# (warning, detail, where): read, but departs from the draft or meets a case it leaves open
|
|
30
|
+
warnings: list[tuple[str, str, str]] = field(default_factory=list)
|
|
31
|
+
# Files whose report bytes were already read, often the same report as .json and .eml
|
|
32
|
+
copies: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Mail:
|
|
37
|
+
"""How a report arrived by email, for the section 8 checks."""
|
|
38
|
+
name: str # attachment file name as sent
|
|
39
|
+
ctype: str # attachment Content-Type
|
|
40
|
+
gz: bool
|
|
41
|
+
subject: str
|
|
42
|
+
msg_ctype: str # Content-Type of the message that carries it
|
|
43
|
+
|
|
44
|
+
DUPLICATE_DAY = ("the same day came twice from one provider; the first copy read was kept. "
|
|
45
|
+
"The draft leaves this open (a note in section 8.1)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _files(paths: Iterable[Path]) -> Iterator[Path]:
|
|
49
|
+
for p in paths:
|
|
50
|
+
if p.is_dir():
|
|
51
|
+
yield from sorted(f for f in p.rglob("*") if f.is_file() and (f.name.lower().endswith(SUFFIXES) or _is_mbox(f)))
|
|
52
|
+
else:
|
|
53
|
+
yield p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_mbox(path: Path) -> bool:
|
|
57
|
+
# Apple Mail exports a folder "Name.mbox" holding a file named just "mbox"
|
|
58
|
+
return path.name.lower() == "mbox" or path.name.lower().endswith(".mbox")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _gunzip(data: bytes) -> bytes:
|
|
62
|
+
with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
|
|
63
|
+
out = g.read(MAX_BYTES + 1)
|
|
64
|
+
if len(out) > MAX_BYTES:
|
|
65
|
+
raise ReportError("larger than 10 MB unpacked")
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _leaves(m: Message, owner: Message) -> Iterator[tuple[Message, Message]]:
|
|
70
|
+
"""Each non-multipart part with the email it belongs to; an attached email owns its own parts."""
|
|
71
|
+
if m.get_content_type() == "message/rfc822":
|
|
72
|
+
for inner in m.get_payload():
|
|
73
|
+
yield from _leaves(inner, inner)
|
|
74
|
+
elif m.is_multipart():
|
|
75
|
+
for p in m.get_payload():
|
|
76
|
+
yield from _leaves(p, owner)
|
|
77
|
+
else:
|
|
78
|
+
yield m, owner
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes, Mail]]:
|
|
82
|
+
"""Yield (label, report JSON bytes, how it arrived) for each report attached to one email."""
|
|
83
|
+
for part, owner in _leaves(msg, msg):
|
|
84
|
+
name = part.get_filename() or ""
|
|
85
|
+
fname = name.lower()
|
|
86
|
+
ctype = part.get_content_type()
|
|
87
|
+
data = part.get_payload(decode=True) or b""
|
|
88
|
+
where = f"{label} ({fname or ctype})"
|
|
89
|
+
if len(data) > MAX_BYTES:
|
|
90
|
+
raise ReportError(f"{where} is larger than 10 MB")
|
|
91
|
+
mail = lambda gz: Mail(name, ctype, gz, " ".join(str(owner.get("subject", "")).split()), owner.get_content_type())
|
|
92
|
+
if fname.endswith(".gz") or ctype in ("application/gzip", "application/x-gzip") or data[:2] == b"\x1f\x8b":
|
|
93
|
+
yield where, _gunzip(data), mail(True)
|
|
94
|
+
# By name or type, or an attachment of unhelpful type that is a JSON array or object
|
|
95
|
+
elif fname.endswith(".json") or ctype == "application/json" or (
|
|
96
|
+
part.get_content_disposition() == "attachment" and data.lstrip()[:1] in (b"[", b"{")):
|
|
97
|
+
yield where, data, mail(False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _payloads(path: Path) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
101
|
+
"""Yield (label, report JSON bytes, how it arrived) for each report in a file."""
|
|
102
|
+
data = path.read_bytes()
|
|
103
|
+
if len(data) > MAX_BYTES:
|
|
104
|
+
raise ReportError("larger than 10 MB")
|
|
105
|
+
if path.name.lower().endswith(".eml"):
|
|
106
|
+
found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), str(path)))
|
|
107
|
+
if not found:
|
|
108
|
+
raise ReportError("no report attachment, which section 8 calls for")
|
|
109
|
+
yield from found
|
|
110
|
+
elif data[:2] == b"\x1f\x8b":
|
|
111
|
+
yield str(path), _gunzip(data), None
|
|
112
|
+
else:
|
|
113
|
+
yield str(path), data, None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
117
|
+
"""Yield reports from a mailbox export; one skip line for all mail with none attached."""
|
|
118
|
+
box = mailbox.mbox(path, factory=lambda f: BytesParser(policy=policy.default).parse(f), create=False)
|
|
119
|
+
without = 0
|
|
120
|
+
try:
|
|
121
|
+
for i, msg in enumerate(box, 1):
|
|
122
|
+
label = f"{path} message {i}"
|
|
123
|
+
try:
|
|
124
|
+
found = list(_attachments(msg, label))
|
|
125
|
+
# OSError covers gzip.BadGzipFile; one bad message must not end the mailbox
|
|
126
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
127
|
+
out.skipped.append((label, str(e)))
|
|
128
|
+
continue
|
|
129
|
+
without += not found
|
|
130
|
+
yield from found
|
|
131
|
+
finally:
|
|
132
|
+
box.close()
|
|
133
|
+
if without:
|
|
134
|
+
out.skipped.append((str(path), f"{without} message{'s' if without != 1 else ''} with no report attached"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _differs(kept: Report, dropped: Report) -> str:
|
|
138
|
+
if kept.version != dropped.version:
|
|
139
|
+
return f", version {kept.version or 'none'} kept, version {dropped.version or 'none'} dropped"
|
|
140
|
+
return ", with different counts" if kept.segments != dropped.segments else ", same counts"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _name(r: Report) -> str:
|
|
144
|
+
# Section 8: the ending date, then domain, selector and source with spaces removed
|
|
145
|
+
return f"{r.day:%Y%m%d}_{r.dkim_domain}_{r.dkim_selector}_{r.source.replace(' ', '')}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Characters a file name cannot hold on common systems; "Comcast/Xfinity" and a "*" roll-up
|
|
149
|
+
# selector both carry one, and section 8 only says to remove spaces
|
|
150
|
+
UNSAFE = '/\\:*?"<>|'
|
|
151
|
+
NAME_UNSAFE = ("attachment name stands in for a character a file name cannot hold, such as / or *; "
|
|
152
|
+
"section 8 does not say how to write those")
|
|
153
|
+
SUBJECT_UNSAFE = ("subject stands in for / or * as a file name must, though section 8 gives the source "
|
|
154
|
+
"and selector as they are")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _loose(expected: str, actual: str) -> bool:
|
|
158
|
+
"""Equal, ignoring case, if each file-name-unsafe character in expected may be any character."""
|
|
159
|
+
pattern = "".join("." if c in UNSAFE else re.escape(c) for c in expected.lower())
|
|
160
|
+
return re.fullmatch(pattern, actual.lower()) is not None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
|
|
164
|
+
out = []
|
|
165
|
+
if mail.msg_ctype != "multipart/report":
|
|
166
|
+
out.append(("message Content-Type is not multipart/report (section 8)", mail.msg_ctype))
|
|
167
|
+
want = "application/gzip" if mail.gz else "application/json"
|
|
168
|
+
if mail.ctype != want:
|
|
169
|
+
out.append(("attachment Content-Type is not application/json, or application/gzip when "
|
|
170
|
+
"compressed (sections 8 and 8.1)", mail.ctype))
|
|
171
|
+
if len(items) > 1:
|
|
172
|
+
out.append(("one attachment holds several reports; the section 8 name and subject fit one", f"{len(items)} reports"))
|
|
173
|
+
elif isinstance(items[0], Report):
|
|
174
|
+
base = _name(items[0])
|
|
175
|
+
file = base + (".json.gz" if mail.gz else ".json")
|
|
176
|
+
if mail.name.lower() == file.lower():
|
|
177
|
+
pass
|
|
178
|
+
elif _loose(file, mail.name):
|
|
179
|
+
out.append((NAME_UNSAFE, f"{mail.name}; section 8 gives {file}"))
|
|
180
|
+
else:
|
|
181
|
+
out.append(("attachment name does not follow section 8: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>.json",
|
|
182
|
+
f"{mail.name or '(none)'}; expected {file}"))
|
|
183
|
+
if mail.subject.lower() == f"APRF: {base}".lower():
|
|
184
|
+
pass
|
|
185
|
+
elif _loose(f"APRF: {base}", mail.subject):
|
|
186
|
+
out.append((SUBJECT_UNSAFE, f"{mail.subject}; section 8 gives APRF: {base}"))
|
|
187
|
+
else:
|
|
188
|
+
out.append(("subject does not follow section 8: APRF: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>",
|
|
189
|
+
f"{mail.subject or '(none)'}; expected APRF: {base}"))
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load(paths: Iterable[Path]) -> Loaded:
|
|
194
|
+
out = Loaded()
|
|
195
|
+
seen_bytes: set[str] = set()
|
|
196
|
+
seen_keys: dict[tuple, tuple[str, Report]] = {}
|
|
197
|
+
|
|
198
|
+
parsed: dict[str, list | ReportError] = {}
|
|
199
|
+
|
|
200
|
+
def add(where: str, data: bytes, mail: Mail | None) -> None:
|
|
201
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
202
|
+
copy = digest in parsed
|
|
203
|
+
if not copy:
|
|
204
|
+
try:
|
|
205
|
+
parsed[digest] = parse_items(data)
|
|
206
|
+
except ReportError as e:
|
|
207
|
+
parsed[digest] = e
|
|
208
|
+
items = parsed[digest]
|
|
209
|
+
# An email is checked even when its report was already read from another file
|
|
210
|
+
if mail is not None and not isinstance(items, ReportError):
|
|
211
|
+
out.warnings += [(w, d, where) for w, d in _mail_warnings(mail, items)]
|
|
212
|
+
if copy:
|
|
213
|
+
out.copies += 1
|
|
214
|
+
return
|
|
215
|
+
if isinstance(items, ReportError):
|
|
216
|
+
out.skipped.append((where, str(items)))
|
|
217
|
+
return
|
|
218
|
+
for i, r in enumerate(items, 1):
|
|
219
|
+
label = f"{where} report {i}" if len(items) > 1 else where
|
|
220
|
+
if isinstance(r, ReportError):
|
|
221
|
+
out.skipped.append((label, str(r)))
|
|
222
|
+
continue
|
|
223
|
+
out.warnings += [(w, d, label) for w, d in r.warnings]
|
|
224
|
+
# The draft leaves open whether a resend replaces the first (an open note in
|
|
225
|
+
# section 8.1), and its version is a free string with no order; keep the first
|
|
226
|
+
if r.key in seen_keys:
|
|
227
|
+
first_label, first = seen_keys[r.key]
|
|
228
|
+
out.warnings.append((DUPLICATE_DAY, f"{first_label} kept{_differs(first, r)}", label))
|
|
229
|
+
continue
|
|
230
|
+
seen_keys[r.key] = (label, r)
|
|
231
|
+
out.reports.append(r)
|
|
232
|
+
|
|
233
|
+
for path in _files(paths):
|
|
234
|
+
try:
|
|
235
|
+
# 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))
|
|
237
|
+
for where, data, mail in payloads:
|
|
238
|
+
add(where, data, mail)
|
|
239
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
240
|
+
out.skipped.append((str(path), str(e)))
|
|
241
|
+
return out
|
|
@@ -9,7 +9,14 @@ from datetime import date
|
|
|
9
9
|
from . import __version__
|
|
10
10
|
from .load import Loaded
|
|
11
11
|
from .report import DRAFT
|
|
12
|
-
from .summary import Row, Stream, streams
|
|
12
|
+
from .summary import Row, Stream, copies_line, grouped, streams
|
|
13
|
+
|
|
14
|
+
WARNINGS_NOTE = "Read anyway. Each names the draft section it departs from, or the case the draft leaves open."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _warning(w: str, times: int, where: str, detail: str) -> str:
|
|
18
|
+
return f"{w}. {_plural(times, 'time')}, first in {where}" + (f": {detail}" if detail else "")
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
NO_COUNTS = "These reports carry no counts."
|
|
15
22
|
|
|
@@ -67,8 +74,12 @@ def to_markdown(loaded: Loaded) -> str:
|
|
|
67
74
|
lines += ["Changes worth a look:", ""] + [f"- {_md(c)}" for c in s.changes] + [""]
|
|
68
75
|
if s.notes:
|
|
69
76
|
lines += [f"- {_md(n)}" for n in s.notes] + [""]
|
|
77
|
+
if loaded.warnings:
|
|
78
|
+
lines += ["## Warnings", "", WARNINGS_NOTE, ""] + [f"- {_md(_warning(*g))}" for g in grouped(loaded.warnings)] + [""]
|
|
70
79
|
if loaded.skipped:
|
|
71
80
|
lines += ["## Skipped", ""] + [f"- {_md(w)}: {_md(r)}" for w, r in loaded.skipped] + [""]
|
|
81
|
+
if loaded.copies:
|
|
82
|
+
lines += [copies_line(loaded.copies), ""]
|
|
72
83
|
lines.append(f"Read by aprf-reader {__version__} against {DRAFT}.")
|
|
73
84
|
return "\n".join(lines) + "\n"
|
|
74
85
|
|
|
@@ -134,8 +145,13 @@ def to_html(loaded: Loaded) -> str:
|
|
|
134
145
|
parts.append("<p>Changes worth a look:</p><ul>" + "".join(f"<li>{e(c)}" for c in s.changes) + "</ul>")
|
|
135
146
|
if s.notes:
|
|
136
147
|
parts.append("<ul>" + "".join(f"<li>{e(n)}" for n in s.notes) + "</ul>")
|
|
148
|
+
if loaded.warnings:
|
|
149
|
+
parts.append(f"<h2>Warnings</h2><p class=muted>{WARNINGS_NOTE}</p><ul>"
|
|
150
|
+
+ "".join(f"<li>{e(_warning(*g))}" for g in grouped(loaded.warnings)) + "</ul>")
|
|
137
151
|
if loaded.skipped:
|
|
138
152
|
parts.append("<h2>Skipped</h2><ul>" + "".join(f"<li>{e(w)}: {e(r)}" for w, r in loaded.skipped) + "</ul>")
|
|
153
|
+
if loaded.copies:
|
|
154
|
+
parts.append(f"<p class=muted>{e(copies_line(loaded.copies))}</p>")
|
|
139
155
|
parts.append(f"<p class=muted>Read by aprf-reader {e(__version__)} against {DRAFT}. Nothing left this computer.</p>")
|
|
140
156
|
parts.append("</body></html>")
|
|
141
157
|
return "\n".join(parts) + "\n"
|
|
@@ -232,6 +248,14 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
|
|
|
232
248
|
for n in s.notes:
|
|
233
249
|
out += _wrap(n, width - 4, " · ")
|
|
234
250
|
out.append("")
|
|
251
|
+
if loaded.warnings:
|
|
252
|
+
out.append(st.warn(f"Warnings {len(grouped(loaded.warnings))}"))
|
|
253
|
+
out += [st.dim(line) for line in _wrap(WARNINGS_NOTE, width - 2, " ")]
|
|
254
|
+
for g in grouped(loaded.warnings):
|
|
255
|
+
out += _wrap(_warning(*g), width - 2, " ")
|
|
256
|
+
out.append("")
|
|
257
|
+
if loaded.copies:
|
|
258
|
+
out += [st.dim(line) for line in _wrap(copies_line(loaded.copies), width)] + [""]
|
|
235
259
|
if loaded.skipped:
|
|
236
260
|
out.append(st.warn(f"Skipped {len(loaded.skipped)}"))
|
|
237
261
|
for w, r in loaded.skipped:
|
|
@@ -7,6 +7,7 @@ edit to this file.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
import re
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from datetime import UTC, date, datetime
|
|
12
13
|
|
|
@@ -25,6 +26,14 @@ def uses_sdi(value: str) -> bool:
|
|
|
25
26
|
return first not in NO_SDI
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
DAY = 86400
|
|
30
|
+
ONE_DAY = "not one UTC day from 00:00:00 to 23:59:59, which draft section 5.1 requires"
|
|
31
|
+
# Placement names section 6 gives as examples; engagement names section 5.2.2 lists
|
|
32
|
+
NAMED_CLASSIFICATION = ("inbox", "unwanted", "forwarded", "promotional")
|
|
33
|
+
# sdi as published in DNS (section 4): a header field name, a comma, one separator character
|
|
34
|
+
SDI_FORMAT = re.compile(r"[!-9;-~]+,\S")
|
|
35
|
+
|
|
36
|
+
|
|
28
37
|
class ReportError(ValueError):
|
|
29
38
|
"""The data is not an APRF report this module can read."""
|
|
30
39
|
|
|
@@ -50,6 +59,8 @@ class Report:
|
|
|
50
59
|
contact_info: str
|
|
51
60
|
extra_info: str
|
|
52
61
|
segments: tuple[Segment, ...]
|
|
62
|
+
# (warning, detail) where the report departs from the draft but can still be read
|
|
63
|
+
warnings: tuple[tuple[str, str], ...] = ()
|
|
53
64
|
|
|
54
65
|
@property
|
|
55
66
|
def day(self) -> date:
|
|
@@ -95,23 +106,23 @@ def _int(v: object) -> int | None:
|
|
|
95
106
|
return v if isinstance(v, int) else None
|
|
96
107
|
|
|
97
108
|
|
|
98
|
-
def _counts(value: object, where: str) -> dict[str, int]:
|
|
109
|
+
def _counts(value: object, where: str, section: str) -> dict[str, int]:
|
|
99
110
|
if value is None:
|
|
100
111
|
return {}
|
|
101
112
|
if not isinstance(value, dict):
|
|
102
|
-
raise ReportError(f"{where} is not an object")
|
|
113
|
+
raise ReportError(f"{where} is not an object ({section})")
|
|
103
114
|
out = {}
|
|
104
115
|
for name, n in value.items():
|
|
105
116
|
k = _int(n)
|
|
106
117
|
if k is None or k < 0 or k > MAX_COUNT:
|
|
107
|
-
raise ReportError(f"{where}.{name} is not a count: {json.dumps(n, ensure_ascii=False)}")
|
|
118
|
+
raise ReportError(f"{where}.{name} is not a count ({section}): {json.dumps(n, ensure_ascii=False, separators=(',', ':'))}")
|
|
108
119
|
out[str(name)] = k
|
|
109
120
|
return out
|
|
110
121
|
|
|
111
122
|
|
|
112
123
|
def _segment(item: object, i: int) -> Segment:
|
|
113
124
|
if not isinstance(item, dict):
|
|
114
|
-
raise ReportError(f"body[{i}] is not an object")
|
|
125
|
+
raise ReportError(f"body[{i}] is not an object (section 5.2.2)")
|
|
115
126
|
seg = item.get("segment")
|
|
116
127
|
# Section 5.2.2 shows a string, the samples a list
|
|
117
128
|
if seg is None:
|
|
@@ -121,11 +132,11 @@ def _segment(item: object, i: int) -> Segment:
|
|
|
121
132
|
elif isinstance(seg, list) and all(isinstance(s, str) for s in seg):
|
|
122
133
|
path = tuple(seg)
|
|
123
134
|
else:
|
|
124
|
-
raise ReportError(f"body[{i}].segment is not a string or list of strings")
|
|
135
|
+
raise ReportError(f"body[{i}].segment is not a string or list of strings (section 5.2.2)")
|
|
125
136
|
return Segment(
|
|
126
137
|
path,
|
|
127
|
-
_counts(item.get("classification"), f"body[{i}].classification"),
|
|
128
|
-
_counts(item.get("engagement"), f"body[{i}].engagement"),
|
|
138
|
+
_counts(item.get("classification"), f"body[{i}].classification", "section 6"),
|
|
139
|
+
_counts(item.get("engagement"), f"body[{i}].engagement", "section 7"),
|
|
129
140
|
)
|
|
130
141
|
|
|
131
142
|
|
|
@@ -135,27 +146,30 @@ def _text(value: object) -> str:
|
|
|
135
146
|
|
|
136
147
|
def _report(obj: object) -> Report:
|
|
137
148
|
if not isinstance(obj, dict) or not isinstance(obj.get("header"), dict):
|
|
138
|
-
raise ReportError("no header object")
|
|
149
|
+
raise ReportError("no header object (section 5.2.1)")
|
|
139
150
|
h = obj["header"]
|
|
140
151
|
for name in ("dkim_domain", "dkim_selector"):
|
|
141
152
|
if not isinstance(h.get(name), str) or not h[name]:
|
|
142
|
-
raise ReportError(f"header.{name} is missing")
|
|
153
|
+
raise ReportError(f"header.{name} is missing (section 5.2.1)")
|
|
143
154
|
start, end = _int(h.get("report_start")), _int(h.get("report_end"))
|
|
144
155
|
for name, v in (("report_start", start), ("report_end", end)):
|
|
145
156
|
if v is None:
|
|
146
|
-
raise ReportError(f"header.{name} is not an epoch time")
|
|
157
|
+
raise ReportError(f"header.{name} is not an epoch time (section 5.2.1)")
|
|
147
158
|
# Seconds past 1e11 are beyond the year 5138; up to 1e14 that is milliseconds
|
|
148
159
|
if 10**11 <= abs(v) < 10**14:
|
|
149
|
-
raise ReportError(f"header.{name} looks like milliseconds, not seconds: {v}")
|
|
160
|
+
raise ReportError(f"header.{name} looks like milliseconds, not epoch seconds (section 5.2.1): {v}")
|
|
150
161
|
if end < start:
|
|
151
|
-
raise ReportError("header.report_end is before report_start")
|
|
162
|
+
raise ReportError("header.report_end is before report_start (section 5.1)")
|
|
152
163
|
try:
|
|
153
164
|
_day(end)
|
|
154
165
|
except (OverflowError, OSError, ValueError):
|
|
155
|
-
raise ReportError(f"header.report_end is out of range: {end}") from None
|
|
166
|
+
raise ReportError(f"header.report_end is out of range (section 5.2.1): {end}") from None
|
|
167
|
+
if start % DAY or end != start + DAY - 1:
|
|
168
|
+
raise ReportError(ONE_DAY)
|
|
156
169
|
body = obj.get("body", [])
|
|
157
170
|
if not isinstance(body, list):
|
|
158
|
-
raise ReportError("body is not a list")
|
|
171
|
+
raise ReportError("body is not a list of segments (section 5.2.2)")
|
|
172
|
+
segments = tuple(_segment(s, i) for i, s in enumerate(body))
|
|
159
173
|
return Report(
|
|
160
174
|
source=_text(h.get("source")) or "unknown",
|
|
161
175
|
dkim_domain=h["dkim_domain"],
|
|
@@ -167,17 +181,44 @@ def _report(obj: object) -> Report:
|
|
|
167
181
|
sdi_used=_text(h.get("sdi_used")).strip(" -"),
|
|
168
182
|
contact_info=_text(h.get("contact_info")),
|
|
169
183
|
extra_info=_text(h.get("extra_info")),
|
|
170
|
-
segments=
|
|
184
|
+
segments=segments,
|
|
185
|
+
warnings=_warnings(h, segments),
|
|
171
186
|
)
|
|
172
187
|
|
|
173
188
|
|
|
189
|
+
def _warnings(h: dict, segments: tuple[Segment, ...]) -> tuple[tuple[str, str], ...]:
|
|
190
|
+
out = []
|
|
191
|
+
if "sdi_used" not in h:
|
|
192
|
+
out.append(("no sdi_used field; section 5.2.1 requires N/A when no SDI is used", ""))
|
|
193
|
+
else:
|
|
194
|
+
raw = h["sdi_used"]
|
|
195
|
+
if not (raw in ("N/A", "N/F") or (isinstance(raw, str) and SDI_FORMAT.fullmatch(raw))):
|
|
196
|
+
out.append(("sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)",
|
|
197
|
+
json.dumps(raw, ensure_ascii=False, separators=(',', ':'))))
|
|
198
|
+
keys = lambda attr: sorted({k for s in segments for k in getattr(s, attr)})
|
|
199
|
+
for k in keys("classification"):
|
|
200
|
+
if k not in NAMED_CLASSIFICATION:
|
|
201
|
+
out.append((f"classification key {json.dumps(k, ensure_ascii=False)} is not one section 6 names "
|
|
202
|
+
"(inbox, unwanted, forwarded, promotional); read as given", ""))
|
|
203
|
+
for k in keys("engagement"):
|
|
204
|
+
if k not in ENGAGEMENT_NAMES:
|
|
205
|
+
out.append((f"engagement key {json.dumps(k, ensure_ascii=False)} is not positive, neutral or "
|
|
206
|
+
"negative (section 5.2.2); read as given", ""))
|
|
207
|
+
return tuple(out)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _no_constant(name: str) -> None:
|
|
211
|
+
# Python's json takes NaN and Infinity; RFC 8259 and browsers do not
|
|
212
|
+
raise json.JSONDecodeError(f"{name} is not valid JSON", name, 0)
|
|
213
|
+
|
|
214
|
+
|
|
174
215
|
def parse_items(data: bytes) -> list[Report | ReportError]:
|
|
175
216
|
"""Each report in a document, or the reason it cannot be read, so one bad
|
|
176
217
|
report does not cost the rest of the file."""
|
|
177
218
|
try:
|
|
178
|
-
doc = json.loads(data)
|
|
219
|
+
doc = json.loads(data, parse_constant=_no_constant)
|
|
179
220
|
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
180
|
-
raise ReportError(f"not JSON: {e}") from None
|
|
221
|
+
raise ReportError(f"not valid JSON, which section 5 requires: {e}") from None
|
|
181
222
|
except RecursionError:
|
|
182
223
|
raise ReportError("JSON nested too deeply") from None
|
|
183
224
|
items = doc if isinstance(doc, list) else [doc]
|
|
@@ -119,8 +119,25 @@ def streams(reports: list[Report]) -> list[Stream]:
|
|
|
119
119
|
return [Stream(src, dom, sel, days) for (src, dom, sel), days in sorted(groups.items())]
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
def
|
|
122
|
+
def grouped(warnings: list[tuple[str, str, str]]) -> list[tuple[str, int, str, str]]:
|
|
123
|
+
"""(warning, times, first where, first detail), in the order first seen."""
|
|
124
|
+
out: dict[str, list] = {}
|
|
125
|
+
for w, d, where in warnings:
|
|
126
|
+
if w in out:
|
|
127
|
+
out[w][1] += 1
|
|
128
|
+
else:
|
|
129
|
+
out[w] = [w, 1, where, d]
|
|
130
|
+
return [tuple(v) for v in out.values()]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def copies_line(n: int) -> str:
|
|
134
|
+
return (f"{n} file{'s' if n != 1 else ''} repeated a report already read, such as the same report "
|
|
135
|
+
"saved as .json and as .eml, and counted once.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def to_dict(loaded) -> dict:
|
|
123
139
|
"""The summary as plain data, for --json and for checking the web page against."""
|
|
140
|
+
reports, skipped = loaded.reports, loaded.skipped
|
|
124
141
|
return {
|
|
125
142
|
"reports": len(reports),
|
|
126
143
|
"streams": [{
|
|
@@ -133,4 +150,6 @@ def to_dict(reports: list[Report], skipped: list[tuple[str, str]]) -> dict:
|
|
|
133
150
|
"changes": s.changes, "notes": s.notes,
|
|
134
151
|
} for s in streams(reports)],
|
|
135
152
|
"skipped": [{"where": w, "reason": r} for w, r in skipped],
|
|
153
|
+
"warnings": [{"warning": w, "times": n, "first": where, "detail": d} for w, n, where, d in grouped(loaded.warnings)],
|
|
154
|
+
"copies": loaded.copies,
|
|
136
155
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -24,7 +24,7 @@ Two ways to use it, with the same results:
|
|
|
24
24
|
your reports on the page. They are read on your computer and never uploaded.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -88,8 +88,62 @@ to mark a change in what a category counts.
|
|
|
88
88
|
or share. Nothing is uploaded anywhere.
|
|
89
89
|
- Follow the draft as it changes. The report format lives in one module
|
|
90
90
|
(`src/aprf_reader/report.py`), so a new draft revision is a small update.
|
|
91
|
-
-
|
|
92
|
-
|
|
91
|
+
- Check each report against the draft and skip, with the reason, any it
|
|
92
|
+
cannot trust. One bad report in a file does not cost the rest of the file.
|
|
93
|
+
|
|
94
|
+
## Checking against the draft
|
|
95
|
+
|
|
96
|
+
Every report is checked against draft-ietf-mailmaint-aprf-00, and every
|
|
97
|
+
message names the section, so a list of them can go straight into a review of
|
|
98
|
+
the draft. On the web page, Copy for a draft review copies them as text.
|
|
99
|
+
|
|
100
|
+
**Skipped**, because the report cannot be trusted:
|
|
101
|
+
|
|
102
|
+
| Problem | Section |
|
|
103
|
+
|---|---|
|
|
104
|
+
| Not valid JSON | 5 |
|
|
105
|
+
| A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
|
|
106
|
+
| Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
|
|
107
|
+
| Body not a list of segments; a segment not an object | 5.2.2 |
|
|
108
|
+
| A placement or engagement count that is not a whole number | 6, 7 |
|
|
109
|
+
| A saved email with no report attached | 8 |
|
|
110
|
+
|
|
111
|
+
**Warnings**, where the report is still read:
|
|
112
|
+
|
|
113
|
+
| Warning | Section |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
|
|
116
|
+
| Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
|
|
117
|
+
| Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
|
|
118
|
+
| One attachment holding several reports | 8 |
|
|
119
|
+
| A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
|
|
120
|
+
| A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
|
|
121
|
+
| The same day twice from one provider | left open, note in 8.1 |
|
|
122
|
+
|
|
123
|
+
Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
|
|
124
|
+
of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
|
|
125
|
+
correct file name for them cannot follow section 8 exactly. A file that
|
|
126
|
+
repeats a report already read, such as the same report as `.json` and as
|
|
127
|
+
`.eml`, is counted once and summed up in one line.
|
|
128
|
+
|
|
129
|
+
## How it decides
|
|
130
|
+
|
|
131
|
+
- **The same day twice, from the same provider:** it keeps the first copy it
|
|
132
|
+
reads and skips the other, saying whether the skipped copy had a different
|
|
133
|
+
`version` or different counts. The draft leaves resends open (a note in
|
|
134
|
+
section 8.1 asks whether a later report should overwrite or be discarded),
|
|
135
|
+
and `version` is a free string with no order, so there is no defined way
|
|
136
|
+
to pick the newer one. Files are read in name order, so the first copy is
|
|
137
|
+
the first by file name, then by position in the file.
|
|
138
|
+
- **The same day from two providers:** both are kept, as separate streams.
|
|
139
|
+
- **Names:** signing domains and selectors compare without case, and an
|
|
140
|
+
international domain matches its `xn--` form, so `bücher.example` and
|
|
141
|
+
`xn--bcher-kva.example` are one stream.
|
|
142
|
+
- **The reporting window:** section 5.1 says a report MUST cover one UTC day,
|
|
143
|
+
from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
|
|
144
|
+
day boundary, is skipped with a reason that names section 5.1.
|
|
145
|
+
- **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
|
|
146
|
+
numbers are refused.
|
|
93
147
|
|
|
94
148
|
## How the web page is built
|
|
95
149
|
|
|
@@ -88,9 +88,12 @@ def test_load_gz_eml_folder_and_skips(tmp_path):
|
|
|
88
88
|
|
|
89
89
|
loaded = load([tmp_path])
|
|
90
90
|
assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3]
|
|
91
|
-
|
|
92
|
-
assert
|
|
93
|
-
|
|
91
|
+
assert [Path(w).name for w, _ in loaded.skipped] == ["broken.json"]
|
|
92
|
+
assert loaded.copies == 1 # copy.json has a.json's bytes
|
|
93
|
+
warned = {(w.split(";")[0].split(".")[0], Path(where.split(" ")[0]).name) for w, _, where in loaded.warnings}
|
|
94
|
+
assert ("the same day came twice from one provider", "resend.json") in warned
|
|
95
|
+
# The test email is multipart/mixed; its name and subject follow section 8
|
|
96
|
+
assert {w for w, _, where in loaded.warnings if "c.eml" in where} == {"message Content-Type is not multipart/report (section 8)"}
|
|
94
97
|
|
|
95
98
|
|
|
96
99
|
def report_email(day: int, payload: bytes, subtype: str, filename: str | None) -> EmailMessage:
|
|
@@ -238,14 +241,25 @@ def test_cli_terminal_when_interactive(monkeypatch, capsys):
|
|
|
238
241
|
|
|
239
242
|
def test_stress_set_one_bad_report_does_not_cost_the_file():
|
|
240
243
|
loaded = load([FIX / "stress_100.json"])
|
|
241
|
-
assert len(loaded.reports) ==
|
|
244
|
+
assert len(loaded.reports) == 86
|
|
242
245
|
reasons = {w.rsplit(" ", 1)[-1]: r for w, r in loaded.skipped}
|
|
243
|
-
|
|
244
|
-
assert reasons["
|
|
246
|
+
# s18 spans two days and s19 is shifted an hour: draft section 5.1 requires one UTC day
|
|
247
|
+
assert "section 5.1" in reasons["77"] and "section 5.1" in reasons["78"]
|
|
248
|
+
# Same day from the same provider: keep the first, warn, and say how the copy differs
|
|
249
|
+
dup = {where.rsplit(" ", 1)[-1]: d for w, d, where in loaded.warnings if w.startswith("the same day came twice")}
|
|
250
|
+
assert dup["64"] == f"{FIX}/stress_100.json report 63 kept, version 1 kept, version 2 dropped"
|
|
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
|
|
253
|
+
assert not {"64", "69", "70"} & set(reasons)
|
|
254
|
+
warned = {w for w, _, _ in loaded.warnings}
|
|
255
|
+
assert "sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)" in warned
|
|
256
|
+
assert any(w.startswith('classification key "spam"') for w in warned)
|
|
257
|
+
assert not any('"promotional"' in w or '"forwarded"' in w for w in warned) # section 6 names them
|
|
258
|
+
assert reasons["73"] == 'body[0].engagement.positive is not a count (section 7): "100"'
|
|
259
|
+
assert reasons["79"] == "header.report_end is before report_start (section 5.1)"
|
|
245
260
|
assert reasons["81"].startswith("header.report_start looks like milliseconds")
|
|
246
|
-
assert "report 63" in reasons["64"] # bücher.example is xn--bcher-kva.example
|
|
247
|
-
assert "report 3" in reasons["70"] # DNS-AUDIT.COM / SELECTOR1 is dns-audit.com / selector1
|
|
248
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
|
|
249
263
|
obj = report(0)
|
|
250
264
|
obj["body"][0]["classification"]["unwanted"] = 100.0
|
|
251
265
|
assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
|
|
@@ -29,7 +29,7 @@ def web(mode: str, paths: list[Path]) -> str:
|
|
|
29
29
|
|
|
30
30
|
def same(paths: list[Path]) -> None:
|
|
31
31
|
loaded = load(paths)
|
|
32
|
-
assert json.loads(web("--json", paths)) == to_dict(loaded
|
|
32
|
+
assert json.loads(web("--json", paths)) == to_dict(loaded)
|
|
33
33
|
assert web("--md", paths) == to_markdown(loaded)
|
|
34
34
|
assert web("--html", paths) == to_html(loaded)
|
|
35
35
|
|
|
@@ -104,3 +104,27 @@ def test_hostile_text_escaped_the_same(tmp_path):
|
|
|
104
104
|
obj["body"][0]["classification"][hostile] = 7
|
|
105
105
|
(tmp_path / "h.json").write_text(json.dumps(obj))
|
|
106
106
|
same([tmp_path / "h.json"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_section_8_checks_the_same(tmp_path):
|
|
110
|
+
raw = json.dumps(report(9)).encode() # 2026-10-10, Example.com, s1, Comcast/Xfinity
|
|
111
|
+
good = email(raw, "json", "20261010_Example.com_s1_Comcast/Xfinity.json")
|
|
112
|
+
good.replace_header("Subject", "APRF: 20261010_Example.com_s1_Comcast/Xfinity")
|
|
113
|
+
good.set_type("multipart/report")
|
|
114
|
+
safe = email(gzip.compress(json.dumps(report(10)).encode()), "gzip", "20261011_example.com_s1_Comcast-Xfinity.json.gz")
|
|
115
|
+
safe.replace_header("Subject", "APRF: 20261011_example.com_s1_Comcast-Xfinity")
|
|
116
|
+
wrong = email(json.dumps(report(11)).encode(), "octet-stream", "report.json")
|
|
117
|
+
paths = []
|
|
118
|
+
for name, msg in (("good.eml", good), ("safe.eml", safe), ("wrong.eml", wrong)):
|
|
119
|
+
(tmp_path / name).write_bytes(msg.as_bytes())
|
|
120
|
+
paths.append(tmp_path / name)
|
|
121
|
+
same(paths)
|
|
122
|
+
loaded = load(paths)
|
|
123
|
+
by_file = {}
|
|
124
|
+
for w, _, where in loaded.warnings:
|
|
125
|
+
by_file.setdefault(Path(where.split(" ")[0]).name, set()).add(w.split(";")[0].split(":")[0].split(" (")[0])
|
|
126
|
+
# Name, subject and both Content-Types follow section 8; only Comcast's beta sdi_used remains
|
|
127
|
+
assert by_file["good.eml"] == {"sdi_used is not N/A, N/F, or the sdi value as published in DNS"}
|
|
128
|
+
assert "attachment name stands in for a character a file name cannot hold, such as / or *" in by_file["safe.eml"]
|
|
129
|
+
assert {"attachment name does not follow section 8", "subject does not follow section 8",
|
|
130
|
+
"attachment Content-Type is not application/json, or application/gzip when compressed"} <= by_file["wrong.eml"]
|
|
@@ -1,143 +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 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_items
|
|
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
|
-
items = parse_items(data)
|
|
120
|
-
except ReportError as e:
|
|
121
|
-
out.skipped.append((where, str(e)))
|
|
122
|
-
return
|
|
123
|
-
for i, r in enumerate(items, 1):
|
|
124
|
-
label = f"{where} report {i}" if len(items) > 1 else where
|
|
125
|
-
if isinstance(r, ReportError):
|
|
126
|
-
out.skipped.append((label, str(r)))
|
|
127
|
-
continue
|
|
128
|
-
# The draft does not yet say whether a resend replaces the first; keep the first
|
|
129
|
-
if r.key in seen_keys:
|
|
130
|
-
out.skipped.append((label, f"same day already reported in {seen_keys[r.key]}; kept that one"))
|
|
131
|
-
continue
|
|
132
|
-
seen_keys[r.key] = label
|
|
133
|
-
out.reports.append(r)
|
|
134
|
-
|
|
135
|
-
for path in _files(paths):
|
|
136
|
-
try:
|
|
137
|
-
# An mbox can be large, so it is read one message at a time
|
|
138
|
-
payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
|
|
139
|
-
for where, data in payloads:
|
|
140
|
-
add(where, data)
|
|
141
|
-
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
142
|
-
out.skipped.append((str(path), str(e)))
|
|
143
|
-
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|