aprf-reader 0.6.1__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.
- {aprf_reader-0.6.1/src/aprf_reader.egg-info → aprf_reader-0.6.2}/PKG-INFO +1 -1
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/pyproject.toml +1 -1
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/cli.py +12 -2
- aprf_reader-0.6.2/src/aprf_reader/imap.py +141 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/load.py +5 -4
- {aprf_reader-0.6.1 → aprf_reader-0.6.2/src/aprf_reader.egg-info}/PKG-INFO +1 -1
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_imap.py +58 -2
- aprf_reader-0.6.1/src/aprf_reader/imap.py +0 -85
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/LICENSE +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/README.md +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/setup.cfg +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/render.py +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/report.py +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/summary.py +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/top_level.txt +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_reader.py +0 -0
- {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_web_parity.py +0 -0
|
@@ -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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -32,6 +32,10 @@ class FakeImap:
|
|
|
32
32
|
return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
|
|
33
33
|
return "OK", [str(len(self.messages)).encode()]
|
|
34
34
|
|
|
35
|
+
def authenticate(self, mech, cb):
|
|
36
|
+
self.calls.append(("authenticate", mech, cb(b"")))
|
|
37
|
+
return "OK", [b"logged in"]
|
|
38
|
+
|
|
35
39
|
def uid(self, command, *args):
|
|
36
40
|
self.calls.append((command, *args))
|
|
37
41
|
if command == "SEARCH":
|
|
@@ -39,7 +43,10 @@ class FakeImap:
|
|
|
39
43
|
out = []
|
|
40
44
|
for u in args[0].split(","):
|
|
41
45
|
body = self.messages[int(u)]
|
|
42
|
-
|
|
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")"]
|
|
43
50
|
return "OK", out
|
|
44
51
|
|
|
45
52
|
def logout(self):
|
|
@@ -60,7 +67,7 @@ def test_fetch_is_read_only_and_batched(monkeypatch):
|
|
|
60
67
|
got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
|
|
61
68
|
assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
|
|
62
69
|
assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
|
|
63
|
-
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]]
|
|
64
71
|
assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
|
|
65
72
|
assert fake.calls[-1] == ("logout",)
|
|
66
73
|
|
|
@@ -116,3 +123,52 @@ def test_certificate_is_checked(monkeypatch):
|
|
|
116
123
|
monkeypatch.setattr(imaplib, "IMAP4_SSL", connect)
|
|
117
124
|
list(fetch("h", "u", "right"))
|
|
118
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,85 +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
|
-
import ssl
|
|
8
|
-
from collections.abc import Iterator
|
|
9
|
-
from datetime import date
|
|
10
|
-
|
|
11
|
-
# Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
|
|
12
|
-
SUBJECT = "APRF:"
|
|
13
|
-
# Messages fetched per round trip
|
|
14
|
-
BATCH = 50
|
|
15
|
-
# A few years of daily reports for a few selectors; more is a wrong folder
|
|
16
|
-
MAX_MESSAGES = 20_000
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class ImapError(Exception):
|
|
20
|
-
pass
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _check(typ: str, data: list, what: str) -> list:
|
|
24
|
-
if typ != "OK":
|
|
25
|
-
detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
|
|
26
|
-
raise ImapError(f"{what} failed: {detail or typ}")
|
|
27
|
-
return data
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _text(e: Exception) -> str:
|
|
31
|
-
# imaplib puts the server's reply bytes in the exception as they came
|
|
32
|
-
a = e.args[0] if e.args else ""
|
|
33
|
-
return a.decode(errors="replace") if isinstance(a, bytes) else str(e)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _quote(s: str) -> str:
|
|
37
|
-
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def criteria(since: date | None = None, every: bool = False) -> list[str]:
|
|
41
|
-
out = [] if every else ["SUBJECT", _quote(SUBJECT)]
|
|
42
|
-
if since:
|
|
43
|
-
out += ["SINCE", since.strftime("%d-%b-%Y")]
|
|
44
|
-
return out or ["ALL"]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
|
|
48
|
-
since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
|
|
49
|
-
"""Yield (label, raw message bytes) for each matching message in one folder.
|
|
50
|
-
|
|
51
|
-
The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
|
|
52
|
-
message is marked read, moved or deleted."""
|
|
53
|
-
# imaplib's own default context checks neither the certificate nor the host name
|
|
54
|
-
m = conn or imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=60)
|
|
55
|
-
try:
|
|
56
|
-
try:
|
|
57
|
-
_check(*m.login(user, password), "login")
|
|
58
|
-
except imaplib.IMAP4.error as e:
|
|
59
|
-
raise ImapError(f"login failed: {_text(e)}") from None
|
|
60
|
-
try:
|
|
61
|
-
_check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
|
|
62
|
-
except imaplib.IMAP4.error as e:
|
|
63
|
-
raise ImapError(f"opening folder {folder} failed: {_text(e)}") from None
|
|
64
|
-
(ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
|
|
65
|
-
uids = (ids or b"").split()
|
|
66
|
-
if len(uids) > MAX_MESSAGES:
|
|
67
|
-
raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
|
|
68
|
-
"pick the folder the reports are filed in, or use --since")
|
|
69
|
-
for i in range(0, len(uids), BATCH):
|
|
70
|
-
chunk = b",".join(uids[i:i + BATCH]).decode()
|
|
71
|
-
data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
|
|
72
|
-
for item in data:
|
|
73
|
-
if not isinstance(item, tuple):
|
|
74
|
-
continue # the closing ")" of each response
|
|
75
|
-
head, body = item
|
|
76
|
-
# Most servers put UID before the body; one that puts it after still has a sequence number
|
|
77
|
-
uid = re.search(rb"UID (\d+)", head)
|
|
78
|
-
which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
|
|
79
|
-
label = f"imap://{host}/{folder} {which}"
|
|
80
|
-
yield label, body
|
|
81
|
-
finally:
|
|
82
|
-
try:
|
|
83
|
-
m.logout()
|
|
84
|
-
except (imaplib.IMAP4.error, OSError):
|
|
85
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|