aprf-reader 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {aprf_reader-0.6.0/src/aprf_reader.egg-info → aprf_reader-0.6.2}/PKG-INFO +2 -2
  2. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/README.md +1 -1
  3. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/pyproject.toml +1 -1
  4. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/__init__.py +1 -1
  5. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/cli.py +12 -2
  6. aprf_reader-0.6.2/src/aprf_reader/imap.py +141 -0
  7. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/load.py +5 -4
  8. {aprf_reader-0.6.0 → aprf_reader-0.6.2/src/aprf_reader.egg-info}/PKG-INFO +2 -2
  9. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/tests/test_imap.py +73 -4
  10. aprf_reader-0.6.0/src/aprf_reader/imap.py +0 -77
  11. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/LICENSE +0 -0
  12. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/setup.cfg +0 -0
  13. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/render.py +0 -0
  14. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/report.py +0 -0
  15. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader/summary.py +0 -0
  16. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
  17. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
  18. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/entry_points.txt +0 -0
  19. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/requires.txt +0 -0
  20. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/top_level.txt +0 -0
  21. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/tests/test_reader.py +0 -0
  22. {aprf_reader-0.6.0 → aprf_reader-0.6.2}/tests/test_web_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -79,7 +79,7 @@ marked read, moved or deleted. It reads messages whose subject contains
79
79
  from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
80
80
  Outlook.com need an app password, not your account password, and a Gmail
81
81
  label is read as a folder of the same name. The connection uses TLS on port
82
- 993; give another port as `--imap host:port`. Files it skips are listed
82
+ 993 and checks the server's certificate; give another port as `--imap host:port`. Files it skips are listed
83
83
  on stderr and at the end of the summary, each with a reason.
84
84
 
85
85
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -66,7 +66,7 @@ marked read, moved or deleted. It reads messages whose subject contains
66
66
  from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
67
67
  Outlook.com need an app password, not your account password, and a Gmail
68
68
  label is read as a folder of the same name. The connection uses TLS on port
69
- 993; give another port as `--imap host:port`. Files it skips are listed
69
+ 993 and checks the server's certificate; give another port as `--imap host:port`. Files it skips are listed
70
70
  on stderr and at the end of the summary, each with a reason.
71
71
 
72
72
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.6.0"
7
+ version = "0.6.2"
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.6.0"
3
+ __version__ = "0.6.2"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import getpass
7
+ import imaplib
7
8
  import json
8
9
  import os
9
10
  import shutil
@@ -49,11 +50,20 @@ def main(argv: list[str] | None = None) -> int:
49
50
  host, _, port = args.imap.partition(":")
50
51
  if port and not port.isdigit():
51
52
  p.error(f"--imap port is not a number: {port}")
52
- password = os.environ.get("APRF_IMAP_PASSWORD") or getpass.getpass(f"IMAP password for {args.user}: ")
53
+ password = os.environ.get("APRF_IMAP_PASSWORD")
54
+ if not password:
55
+ if pasted is not None:
56
+ p.error("stdin is the pasted report, so the password must come from APRF_IMAP_PASSWORD")
57
+ try:
58
+ password = getpass.getpass(f"IMAP password for {args.user}: ")
59
+ except (EOFError, KeyboardInterrupt):
60
+ print(file=sys.stderr)
61
+ p.error("no password: set APRF_IMAP_PASSWORD or run it in a terminal")
53
62
  try:
54
63
  # Fetched in full before reading, so a dropped connection leaves no half summary
55
64
  messages = list(fetch(host, args.user, password, args.folder, int(port or 993), args.since, args.all_mail))
56
- except (ImapError, OSError) as e:
65
+ # imaplib.IMAP4.error and UnicodeError are a backstop; fetch() turns what it can into ImapError
66
+ except (ImapError, imaplib.IMAP4.error, OSError, UnicodeError) as e:
57
67
  print(f"aprf-reader: {args.imap}: {e}", file=sys.stderr)
58
68
  return 2
59
69
  print(f"read {len(messages)} message{'s' if len(messages) != 1 else ''} from {args.folder} on {host}", file=sys.stderr)
@@ -0,0 +1,141 @@
1
+ """Read report emails straight from a mailbox over IMAP, without changing anything there."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import imaplib
7
+ import re
8
+ import ssl
9
+ from collections.abc import Iterator
10
+ from datetime import date
11
+
12
+ from .load import MAX_BYTES
13
+
14
+ # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
15
+ SUBJECT = "APRF:"
16
+ # Messages fetched per round trip
17
+ BATCH = 50
18
+ # A few years of daily reports for a few selectors; more is a wrong folder
19
+ MAX_MESSAGES = 20_000
20
+
21
+
22
+ class ImapError(Exception):
23
+ pass
24
+
25
+
26
+ def _check(typ: str, data: list, what: str) -> list:
27
+ if typ != "OK":
28
+ detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
29
+ raise ImapError(f"{what} failed: {detail or typ}")
30
+ return data
31
+
32
+
33
+ def _text(e: Exception) -> str:
34
+ # imaplib puts the server's reply bytes in the exception as they came
35
+ a = e.args[0] if e.args else ""
36
+ return a.decode(errors="replace") if isinstance(a, bytes) else str(e)
37
+
38
+
39
+ def utf7(name: str) -> str:
40
+ """A folder name in IMAP's modified UTF-7 (RFC 3501 5.1.3), as Exchange and others name
41
+ folders such as "Gelöschte Elemente"; plain ASCII is unchanged."""
42
+ out, run = [], []
43
+
44
+ def flush():
45
+ if run:
46
+ b = base64.b64encode("".join(run).encode("utf-16-be")).decode().rstrip("=").replace("/", ",")
47
+ out.append(f"&{b}-")
48
+ run.clear()
49
+
50
+ for ch in name:
51
+ if 0x20 <= ord(ch) <= 0x7E:
52
+ flush()
53
+ out.append("&-" if ch == "&" else ch)
54
+ else:
55
+ run.append(ch)
56
+ flush()
57
+ return "".join(out)
58
+
59
+
60
+ def _login(m, user: str, password: str) -> None:
61
+ if user.isascii() and password.isascii():
62
+ _check(*m.login(user, password), "login")
63
+ else:
64
+ # LOGIN only carries ASCII; SASL PLAIN carries UTF-8 (RFC 4616)
65
+ if "AUTH=PLAIN" not in getattr(m, "capabilities", ("AUTH=PLAIN",)):
66
+ raise ImapError("login failed: the user name or password has non-ASCII characters, "
67
+ "and this server does not offer AUTH=PLAIN, which is needed to send them")
68
+ creds = b"\0" + user.encode() + b"\0" + password.encode()
69
+ _check(*m.authenticate("PLAIN", lambda _: creds), "login")
70
+
71
+
72
+ def _quote(s: str) -> str:
73
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
74
+
75
+
76
+ def criteria(since: date | None = None, every: bool = False) -> list[str]:
77
+ out = [] if every else ["SUBJECT", _quote(SUBJECT)]
78
+ if since:
79
+ out += ["SINCE", since.strftime("%d-%b-%Y")]
80
+ return out or ["ALL"]
81
+
82
+
83
+ def _messages(m, host: str, folder: str, since: date | None, every: bool) -> Iterator[tuple[str, bytes | None]]:
84
+ (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
85
+ uids = (ids or b"").split()
86
+ if len(uids) > MAX_MESSAGES:
87
+ raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
88
+ "pick the folder the reports are filed in, or use --since")
89
+ for i in range(0, len(uids), BATCH):
90
+ chunk = b",".join(uids[i:i + BATCH]).decode()
91
+ # Sizes first, so a huge message is never downloaded
92
+ sizes = {}
93
+ for item in _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE)"), "fetch"):
94
+ line = item[0] if isinstance(item, tuple) else item
95
+ u, n = re.search(rb"UID (\d+)", line or b""), re.search(rb"RFC822\.SIZE (\d+)", line or b"")
96
+ if u and n:
97
+ sizes[u.group(1)] = int(n.group(1))
98
+ big = [u for u in uids[i:i + BATCH] if sizes.get(u, 0) > MAX_BYTES]
99
+ for u in big:
100
+ yield f"imap://{host}/{folder} uid {u.decode()}", None
101
+ small = [u for u in uids[i:i + BATCH] if u not in big]
102
+ if not small:
103
+ continue
104
+ data = _check(*m.uid("FETCH", b",".join(small).decode(), "(UID BODY.PEEK[])"), "fetch")
105
+ for item in data:
106
+ if not isinstance(item, tuple):
107
+ continue # the closing ")" of each response
108
+ head, body = item
109
+ # Most servers put UID before the body; one that puts it after still has a sequence number
110
+ uid = re.search(rb"UID (\d+)", head)
111
+ which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
112
+ yield f"imap://{host}/{folder} {which}", body
113
+
114
+
115
+ def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
116
+ since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes | None]]:
117
+ """Yield (label, raw message bytes) for each matching message in one folder; the
118
+ bytes are None for a message over MAX_BYTES, which is not downloaded.
119
+
120
+ The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
121
+ message is marked read, moved or deleted."""
122
+ # imaplib's own default context checks neither the certificate nor the host name
123
+ m = conn or imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=60)
124
+ try:
125
+ try:
126
+ _login(m, user, password)
127
+ except imaplib.IMAP4.error as e:
128
+ raise ImapError(f"login failed: {_text(e)}") from None
129
+ try:
130
+ _check(*m.select(_quote(utf7(folder)), readonly=True), f"opening folder {folder}")
131
+ except imaplib.IMAP4.error as e:
132
+ raise ImapError(f"opening folder {folder} failed: {_text(e)}") from None
133
+ try:
134
+ yield from _messages(m, host, folder, since, every)
135
+ except imaplib.IMAP4.error as e: # includes abort: the server hung up mid-run
136
+ raise ImapError(f"connection lost: {_text(e)}") from None
137
+ finally:
138
+ try:
139
+ m.logout()
140
+ except (imaplib.IMAP4.error, OSError):
141
+ pass
@@ -232,15 +232,16 @@ def pasted_name(data: bytes) -> str:
232
232
  return "pasted.json.gz" if data[:2] == b"\x1f\x8b" else "pasted.eml"
233
233
 
234
234
 
235
- def _message(label: str, data: bytes) -> list[tuple[str, bytes, Mail]]:
236
- """Reports in one raw email fetched from a mailbox; mail with none is counted, not skipped."""
237
- if len(data) > MAX_BYTES:
235
+ def _message(label: str, data: bytes | None) -> list[tuple[str, bytes, Mail]]:
236
+ """Reports in one raw email fetched from a mailbox; mail with none is counted, not skipped.
237
+ None is a message too large to have been downloaded."""
238
+ if data is None or len(data) > MAX_BYTES:
238
239
  raise ReportError("larger than 10 MB")
239
240
  return list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
240
241
 
241
242
 
242
243
  def load(paths: Iterable[Path], pasted: bytes | None = None,
243
- messages: Iterable[tuple[str, bytes]] = (), source: str = "mailbox") -> Loaded:
244
+ messages: Iterable[tuple[str, bytes | None]] = (), source: str = "mailbox") -> Loaded:
244
245
  out = Loaded()
245
246
  seen_keys: dict[tuple, tuple[str, Report]] = {}
246
247
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -79,7 +79,7 @@ marked read, moved or deleted. It reads messages whose subject contains
79
79
  from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
80
80
  Outlook.com need an app password, not your account password, and a Gmail
81
81
  label is read as a folder of the same name. The connection uses TLS on port
82
- 993; give another port as `--imap host:port`. Files it skips are listed
82
+ 993 and checks the server's certificate; give another port as `--imap host:port`. Files it skips are listed
83
83
  on stderr and at the end of the summary, each with a reason.
84
84
 
85
85
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -1,6 +1,7 @@
1
1
  import gzip
2
2
  import imaplib
3
3
  import json
4
+ import ssl
4
5
  from datetime import date
5
6
 
6
7
  import pytest
@@ -31,6 +32,10 @@ class FakeImap:
31
32
  return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
32
33
  return "OK", [str(len(self.messages)).encode()]
33
34
 
35
+ def authenticate(self, mech, cb):
36
+ self.calls.append(("authenticate", mech, cb(b"")))
37
+ return "OK", [b"logged in"]
38
+
34
39
  def uid(self, command, *args):
35
40
  self.calls.append((command, *args))
36
41
  if command == "SEARCH":
@@ -38,7 +43,10 @@ class FakeImap:
38
43
  out = []
39
44
  for u in args[0].split(","):
40
45
  body = self.messages[int(u)]
41
- out += [(f"{u} (UID {u} RFC822.SIZE {len(body)} BODY[] {{{len(body)}}}".encode(), body), b")"]
46
+ if "BODY" not in args[1]:
47
+ out.append(f"{u} (UID {u} RFC822.SIZE {len(body)})".encode())
48
+ else:
49
+ out += [(f"{u} (UID {u} BODY[] {{{len(body)}}}".encode(), body), b")"]
42
50
  return "OK", out
43
51
 
44
52
  def logout(self):
@@ -59,7 +67,7 @@ def test_fetch_is_read_only_and_batched(monkeypatch):
59
67
  got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
60
68
  assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
61
69
  assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
62
- fetches = [c for c in fake.calls if c[0] == "FETCH"]
70
+ fetches = [c for c in fake.calls if c[0] == "FETCH" and "BODY" in c[2]]
63
71
  assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
64
72
  assert fake.calls[-1] == ("logout",)
65
73
 
@@ -87,7 +95,7 @@ def test_fetched_mail_read_like_an_mbox():
87
95
 
88
96
  def test_cli_imap(monkeypatch, capsys):
89
97
  fake = FakeImap(mails(2))
90
- monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: fake)
98
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: fake)
91
99
  monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
92
100
  assert cli.main(["--imap", "imap.example.com", "--user", "u", "--folder", "APRF", "--json"]) == 0
93
101
  out, err = capsys.readouterr()
@@ -96,10 +104,71 @@ def test_cli_imap(monkeypatch, capsys):
96
104
 
97
105
 
98
106
  def test_cli_imap_failure_and_usage(monkeypatch, capsys):
99
- monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: FakeImap({}))
107
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: FakeImap({}))
100
108
  monkeypatch.setenv("APRF_IMAP_PASSWORD", "wrong")
101
109
  assert cli.main(["--imap", "h", "--user", "u"]) == 2
102
110
  assert "login failed" in capsys.readouterr().err
103
111
  for argv in ([], ["--imap", "h"], ["--imap", "h:x", "--user", "u"]):
104
112
  with pytest.raises(SystemExit):
105
113
  cli.main(argv)
114
+
115
+
116
+ def test_certificate_is_checked(monkeypatch):
117
+ seen = {}
118
+
119
+ def connect(host, port, ssl_context, timeout):
120
+ seen["ctx"] = ssl_context
121
+ return FakeImap({})
122
+
123
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", connect)
124
+ list(fetch("h", "u", "right"))
125
+ assert seen["ctx"].verify_mode == ssl.CERT_REQUIRED and seen["ctx"].check_hostname
126
+
127
+
128
+ def test_huge_message_not_downloaded(monkeypatch):
129
+ monkeypatch.setattr(imap, "MAX_BYTES", 1000)
130
+ msgs = mails(1)
131
+ msgs[200] = b"x" * 2000
132
+ fake = FakeImap(msgs)
133
+ got = dict(fetch("h", "u", "right", conn=fake))
134
+ assert got["imap://h/INBOX uid 200"] is None and got["imap://h/INBOX uid 100"]
135
+ assert all("200" not in c[1] for c in fake.calls if c[0] == "FETCH" and "BODY" in c[2])
136
+ loaded = load([], None, got.items())
137
+ assert loaded.skipped == [("imap://h/INBOX uid 200", "larger than 10 MB")]
138
+
139
+
140
+ def test_connection_lost_mid_run_is_a_clean_error(monkeypatch, capsys):
141
+ fake = FakeImap(mails(2))
142
+
143
+ def hang_up(*a):
144
+ raise imaplib.IMAP4.abort("socket error: EOF")
145
+
146
+ fake.uid = hang_up
147
+ with pytest.raises(ImapError, match="connection lost: socket error: EOF"):
148
+ list(fetch("h", "u", "right", conn=fake))
149
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: fake)
150
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
151
+ assert cli.main(["--imap", "h", "--user", "u"]) == 2
152
+ assert "connection lost" in capsys.readouterr().err
153
+
154
+
155
+ def test_non_ascii_folder_and_password():
156
+ assert imap.utf7("Gelöschte Elemente") == "Gel&APY-schte Elemente"
157
+ assert imap.utf7("日本語") == "&ZeVnLIqe-" # the RFC 3501 example
158
+ assert imap.utf7("A&B") == "A&-B"
159
+ fake = FakeImap({}, folders=("Gel&APY-schte Elemente",))
160
+ list(fetch("h", "u", "sécret", "Gelöschte Elemente", conn=fake))
161
+ assert ("authenticate", "PLAIN", "\0u\0sécret".encode()) in fake.calls
162
+ assert ("select", '"Gel&APY-schte Elemente"', True) in fake.calls
163
+
164
+
165
+ def test_no_terminal_for_the_password(monkeypatch, capsys):
166
+ monkeypatch.delenv("APRF_IMAP_PASSWORD", raising=False)
167
+
168
+ def no_tty(prompt):
169
+ raise EOFError
170
+
171
+ monkeypatch.setattr(cli.getpass, "getpass", no_tty)
172
+ with pytest.raises(SystemExit):
173
+ cli.main(["--imap", "h", "--user", "u"])
174
+ assert "set APRF_IMAP_PASSWORD" in capsys.readouterr().err
@@ -1,77 +0,0 @@
1
- """Read report emails straight from a mailbox over IMAP, without changing anything there."""
2
-
3
- from __future__ import annotations
4
-
5
- import imaplib
6
- import re
7
- from collections.abc import Iterator
8
- from datetime import date
9
-
10
- # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
11
- SUBJECT = "APRF:"
12
- # Messages fetched per round trip
13
- BATCH = 50
14
- # A few years of daily reports for a few selectors; more is a wrong folder
15
- MAX_MESSAGES = 20_000
16
-
17
-
18
- class ImapError(Exception):
19
- pass
20
-
21
-
22
- def _check(typ: str, data: list, what: str) -> list:
23
- if typ != "OK":
24
- detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
25
- raise ImapError(f"{what} failed: {detail or typ}")
26
- return data
27
-
28
-
29
- def _quote(s: str) -> str:
30
- return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
31
-
32
-
33
- def criteria(since: date | None = None, every: bool = False) -> list[str]:
34
- out = [] if every else ["SUBJECT", _quote(SUBJECT)]
35
- if since:
36
- out += ["SINCE", since.strftime("%d-%b-%Y")]
37
- return out or ["ALL"]
38
-
39
-
40
- def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
41
- since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
42
- """Yield (label, raw message bytes) for each matching message in one folder.
43
-
44
- The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
45
- message is marked read, moved or deleted."""
46
- m = conn or imaplib.IMAP4_SSL(host, port, timeout=60)
47
- try:
48
- try:
49
- _check(*m.login(user, password), "login")
50
- except imaplib.IMAP4.error as e:
51
- raise ImapError(f"login failed: {e}") from None
52
- try:
53
- _check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
54
- except imaplib.IMAP4.error as e:
55
- raise ImapError(f"opening folder {folder} failed: {e}") from None
56
- (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
57
- uids = (ids or b"").split()
58
- if len(uids) > MAX_MESSAGES:
59
- raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
60
- "pick the folder the reports are filed in, or use --since")
61
- for i in range(0, len(uids), BATCH):
62
- chunk = b",".join(uids[i:i + BATCH]).decode()
63
- data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
64
- for item in data:
65
- if not isinstance(item, tuple):
66
- continue # the closing ")" of each response
67
- head, body = item
68
- # Most servers put UID before the body; one that puts it after still has a sequence number
69
- uid = re.search(rb"UID (\d+)", head)
70
- which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
71
- label = f"imap://{host}/{folder} {which}"
72
- yield label, body
73
- finally:
74
- try:
75
- m.logout()
76
- except (imaplib.IMAP4.error, OSError):
77
- pass
File without changes
File without changes