openmail 0.1.5__py3-none-any.whl
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.
- openmail/__init__.py +6 -0
- openmail/assistants/__init__.py +35 -0
- openmail/assistants/classify_emails.py +83 -0
- openmail/assistants/compose_email.py +43 -0
- openmail/assistants/detect_phishing_for_email.py +61 -0
- openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
- openmail/assistants/extract_tasks_from_emails.py +126 -0
- openmail/assistants/generate_follow_up_for_email.py +54 -0
- openmail/assistants/natural_language_query.py +699 -0
- openmail/assistants/prioritize_emails.py +89 -0
- openmail/assistants/reply.py +58 -0
- openmail/assistants/reply_suggestions.py +46 -0
- openmail/assistants/rewrite_email.py +50 -0
- openmail/assistants/summarize_attachments_for_email.py +101 -0
- openmail/assistants/summarize_thread_emails.py +55 -0
- openmail/assistants/summary.py +44 -0
- openmail/assistants/summary_multi.py +57 -0
- openmail/assistants/translate_email.py +54 -0
- openmail/auth/__init__.py +6 -0
- openmail/auth/base.py +34 -0
- openmail/auth/no_auth.py +19 -0
- openmail/auth/oauth2.py +58 -0
- openmail/auth/password.py +26 -0
- openmail/config.py +26 -0
- openmail/email_assistant.py +418 -0
- openmail/email_manager.py +777 -0
- openmail/email_query.py +279 -0
- openmail/errors.py +16 -0
- openmail/imap/__init__.py +5 -0
- openmail/imap/attachment_parts.py +55 -0
- openmail/imap/bodystructure.py +296 -0
- openmail/imap/client.py +806 -0
- openmail/imap/fetch_response.py +115 -0
- openmail/imap/inline_cid.py +106 -0
- openmail/imap/pagination.py +16 -0
- openmail/imap/parser.py +298 -0
- openmail/imap/query.py +233 -0
- openmail/llm/__init__.py +3 -0
- openmail/llm/claude.py +35 -0
- openmail/llm/costs.py +108 -0
- openmail/llm/gemini.py +34 -0
- openmail/llm/gpt.py +33 -0
- openmail/llm/groq.py +36 -0
- openmail/llm/model.py +126 -0
- openmail/llm/xai.py +35 -0
- openmail/logger.py +20 -0
- openmail/models/__init__.py +20 -0
- openmail/models/attachment.py +128 -0
- openmail/models/message.py +113 -0
- openmail/models/subscription.py +45 -0
- openmail/models/task.py +24 -0
- openmail/py.typed +0 -0
- openmail/smtp/__init__.py +7 -0
- openmail/smtp/builder.py +41 -0
- openmail/smtp/client.py +218 -0
- openmail/smtp/templates.py +16 -0
- openmail/subscription/__init__.py +7 -0
- openmail/subscription/detector.py +58 -0
- openmail/subscription/parser.py +32 -0
- openmail/subscription/service.py +237 -0
- openmail/types.py +30 -0
- openmail/utils/__init__.py +39 -0
- openmail/utils/utils.py +295 -0
- openmail-0.1.5.dist-info/METADATA +180 -0
- openmail-0.1.5.dist-info/RECORD +67 -0
- openmail-0.1.5.dist-info/WHEEL +4 -0
- openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
openmail/imap/client.py
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
# openmail/imap/client.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import imaplib
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from bisect import bisect_left, bisect_right
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from email.message import EmailMessage as PyEmailMessage
|
|
11
|
+
from email.parser import BytesParser
|
|
12
|
+
from email.policy import default as default_policy
|
|
13
|
+
from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
14
|
+
|
|
15
|
+
from openmail import IMAPConfig
|
|
16
|
+
from openmail.auth import AuthContext
|
|
17
|
+
from openmail.errors import ConfigError, IMAPError
|
|
18
|
+
from openmail.imap.attachment_parts import fetch_part_bytes
|
|
19
|
+
from openmail.imap.bodystructure import (
|
|
20
|
+
extract_bodystructure_from_fetch_meta,
|
|
21
|
+
extract_text_and_attachments,
|
|
22
|
+
parse_bodystructure,
|
|
23
|
+
pick_best_text_parts,
|
|
24
|
+
)
|
|
25
|
+
from openmail.imap.fetch_response import (
|
|
26
|
+
has_header_peek,
|
|
27
|
+
iter_fetch_pieces,
|
|
28
|
+
match_section_body,
|
|
29
|
+
match_section_mime,
|
|
30
|
+
parse_flags,
|
|
31
|
+
parse_internaldate,
|
|
32
|
+
parse_uid,
|
|
33
|
+
)
|
|
34
|
+
from openmail.imap.inline_cid import inline_cids_as_data_uris
|
|
35
|
+
from openmail.imap.pagination import PagedSearchResult
|
|
36
|
+
from openmail.imap.parser import (
|
|
37
|
+
decode_body_chunk,
|
|
38
|
+
parse_headers_and_bodies,
|
|
39
|
+
parse_overview,
|
|
40
|
+
)
|
|
41
|
+
from openmail.imap.query import IMAPQuery
|
|
42
|
+
from openmail.models import AttachmentMeta, EmailMessage, EmailOverview
|
|
43
|
+
from openmail.types import EmailRef
|
|
44
|
+
from openmail.utils import parse_list_mailbox_name
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class IMAPClient:
|
|
49
|
+
config: IMAPConfig
|
|
50
|
+
_conn: imaplib.IMAP4 | None = field(default=None, init=False, repr=False)
|
|
51
|
+
_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
|
|
52
|
+
_selected_mailbox: str | None = field(default=None, init=False, repr=False)
|
|
53
|
+
_selected_readonly: bool | None = field(default=None, init=False, repr=False)
|
|
54
|
+
|
|
55
|
+
# cache key: (mailbox, criteria_str) -> ascending UID list
|
|
56
|
+
_search_cache: Dict[tuple[str, str], List[int]] = field(
|
|
57
|
+
default_factory=dict, init=False, repr=False
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
max_retries: int = 1
|
|
61
|
+
backoff_seconds: float = 0.0
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_config(cls, config: IMAPConfig) -> IMAPClient:
|
|
65
|
+
if not config.host:
|
|
66
|
+
raise ConfigError("IMAP host required")
|
|
67
|
+
if not config.port:
|
|
68
|
+
raise ConfigError("IMAP port required")
|
|
69
|
+
return cls(config)
|
|
70
|
+
|
|
71
|
+
# -----------------------
|
|
72
|
+
# Connection management
|
|
73
|
+
# -----------------------
|
|
74
|
+
|
|
75
|
+
def _open_new_connection(self) -> imaplib.IMAP4:
|
|
76
|
+
cfg = self.config
|
|
77
|
+
try:
|
|
78
|
+
conn = (
|
|
79
|
+
imaplib.IMAP4_SSL(cfg.host, cfg.port, timeout=cfg.timeout)
|
|
80
|
+
if cfg.use_ssl
|
|
81
|
+
else imaplib.IMAP4(cfg.host, cfg.port, timeout=cfg.timeout)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if cfg.auth is None:
|
|
85
|
+
raise ConfigError("IMAPConfig.auth is required (PasswordAuth or OAuth2Auth)")
|
|
86
|
+
|
|
87
|
+
cfg.auth.apply_imap(conn, AuthContext(host=cfg.host, port=cfg.port))
|
|
88
|
+
return conn
|
|
89
|
+
|
|
90
|
+
except imaplib.IMAP4.error as e:
|
|
91
|
+
raise IMAPError(f"IMAP connection/auth failed: {e}") from e
|
|
92
|
+
except OSError as e:
|
|
93
|
+
raise IMAPError(f"IMAP network error: {e}") from e
|
|
94
|
+
|
|
95
|
+
def _get_conn(self) -> imaplib.IMAP4:
|
|
96
|
+
# Must be called with self._lock held
|
|
97
|
+
if self._conn is not None:
|
|
98
|
+
return self._conn
|
|
99
|
+
self._conn = self._open_new_connection()
|
|
100
|
+
self._selected_mailbox = None
|
|
101
|
+
self._selected_readonly = None
|
|
102
|
+
return self._conn
|
|
103
|
+
|
|
104
|
+
def _reset_conn(self) -> None:
|
|
105
|
+
# Must be called with self._lock held
|
|
106
|
+
if self._conn is not None:
|
|
107
|
+
try:
|
|
108
|
+
self._conn.logout()
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
self._conn = None
|
|
112
|
+
self._selected_mailbox = None
|
|
113
|
+
self._selected_readonly = None
|
|
114
|
+
|
|
115
|
+
def _run_with_conn(self, op: Callable[[imaplib.IMAP4], object]):
|
|
116
|
+
"""
|
|
117
|
+
Run an operation with thread-safety and reconnect-on-abort retry.
|
|
118
|
+
"""
|
|
119
|
+
last_exc: Optional[BaseException] = None
|
|
120
|
+
attempts = self.max_retries + 1
|
|
121
|
+
|
|
122
|
+
for attempt in range(attempts):
|
|
123
|
+
with self._lock:
|
|
124
|
+
conn = self._get_conn()
|
|
125
|
+
try:
|
|
126
|
+
return op(conn)
|
|
127
|
+
except imaplib.IMAP4.abort as e:
|
|
128
|
+
last_exc = e
|
|
129
|
+
self._reset_conn()
|
|
130
|
+
except imaplib.IMAP4.error as e:
|
|
131
|
+
raise IMAPError(f"IMAP operation failed: {e}") from e
|
|
132
|
+
|
|
133
|
+
if attempt < attempts - 1 and self.backoff_seconds > 0:
|
|
134
|
+
time.sleep(self.backoff_seconds)
|
|
135
|
+
|
|
136
|
+
raise IMAPError(f"IMAP connection repeatedly aborted: {last_exc}") from last_exc
|
|
137
|
+
|
|
138
|
+
# -----------------------
|
|
139
|
+
# Mailbox selection helpers
|
|
140
|
+
# -----------------------
|
|
141
|
+
|
|
142
|
+
def _format_mailbox_arg(self, mailbox: str) -> str:
|
|
143
|
+
if mailbox.upper() == "INBOX":
|
|
144
|
+
return "INBOX"
|
|
145
|
+
if mailbox.startswith('"') and mailbox.endswith('"'):
|
|
146
|
+
return mailbox
|
|
147
|
+
return f'"{mailbox}"'
|
|
148
|
+
|
|
149
|
+
def _ensure_selected(self, conn: imaplib.IMAP4, mailbox: str, readonly: bool) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Cache selected mailbox to avoid repeated SELECT/EXAMINE.
|
|
152
|
+
RW selection satisfies both RW and RO operations.
|
|
153
|
+
RO selection satisfies only RO operations.
|
|
154
|
+
"""
|
|
155
|
+
# Must be called with self._lock held.
|
|
156
|
+
if self._selected_mailbox == mailbox:
|
|
157
|
+
if self._selected_readonly is False:
|
|
158
|
+
return # already RW
|
|
159
|
+
if readonly and self._selected_readonly is True:
|
|
160
|
+
return # already RO and RO requested
|
|
161
|
+
|
|
162
|
+
imap_mailbox = self._format_mailbox_arg(mailbox)
|
|
163
|
+
typ, _ = conn.select(imap_mailbox, readonly=readonly)
|
|
164
|
+
if typ != "OK":
|
|
165
|
+
raise IMAPError(f"select({mailbox!r}, readonly={readonly}) failed")
|
|
166
|
+
|
|
167
|
+
self._selected_mailbox = mailbox
|
|
168
|
+
self._selected_readonly = readonly
|
|
169
|
+
|
|
170
|
+
def _assert_same_mailbox(self, refs: Sequence[EmailRef], op_name: str) -> str:
|
|
171
|
+
if not refs:
|
|
172
|
+
raise IMAPError(f"{op_name} called with empty refs")
|
|
173
|
+
mailbox = refs[0].mailbox
|
|
174
|
+
for r in refs:
|
|
175
|
+
if r.mailbox != mailbox:
|
|
176
|
+
raise IMAPError(
|
|
177
|
+
f"All EmailRef.mailbox must match for {op_name} "
|
|
178
|
+
f"(got {refs[0].mailbox!r} and {r.mailbox!r})"
|
|
179
|
+
)
|
|
180
|
+
return mailbox
|
|
181
|
+
|
|
182
|
+
# -----------------------
|
|
183
|
+
# Cache invalidation
|
|
184
|
+
# -----------------------
|
|
185
|
+
|
|
186
|
+
def _invalidate_search_cache(self, mailbox: Optional[str] = None) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Invalidate cached search results (simple + safe).
|
|
189
|
+
If mailbox is provided, only clear entries for that mailbox.
|
|
190
|
+
"""
|
|
191
|
+
with self._lock:
|
|
192
|
+
if mailbox is None:
|
|
193
|
+
self._search_cache.clear()
|
|
194
|
+
return
|
|
195
|
+
keys = [k for k in self._search_cache.keys() if k[0] == mailbox]
|
|
196
|
+
for k in keys:
|
|
197
|
+
self._search_cache.pop(k, None)
|
|
198
|
+
|
|
199
|
+
# -----------------------
|
|
200
|
+
# LIST parsing
|
|
201
|
+
# -----------------------
|
|
202
|
+
|
|
203
|
+
def _parse_list_flags(self, raw: bytes) -> Set[str]:
|
|
204
|
+
try:
|
|
205
|
+
s = raw.decode(errors="ignore")
|
|
206
|
+
except Exception:
|
|
207
|
+
return set()
|
|
208
|
+
|
|
209
|
+
start = s.find("(")
|
|
210
|
+
end = s.find(")", start + 1)
|
|
211
|
+
if start == -1 or end == -1 or end <= start + 1:
|
|
212
|
+
return set()
|
|
213
|
+
|
|
214
|
+
flags_str = s[start + 1 : end].strip()
|
|
215
|
+
if not flags_str:
|
|
216
|
+
return set()
|
|
217
|
+
|
|
218
|
+
return {f.upper() for f in flags_str.split() if f.strip()}
|
|
219
|
+
|
|
220
|
+
# -----------------------
|
|
221
|
+
# SEARCH + pagination
|
|
222
|
+
# -----------------------
|
|
223
|
+
|
|
224
|
+
def refresh_search_cache(self, *, mailbox: str, query: IMAPQuery) -> List[int]:
|
|
225
|
+
criteria = query.build() or "ALL"
|
|
226
|
+
cache_key = (mailbox, criteria)
|
|
227
|
+
|
|
228
|
+
def _impl(conn: imaplib.IMAP4) -> List[int]:
|
|
229
|
+
self._ensure_selected(conn, mailbox, readonly=True)
|
|
230
|
+
typ, data = conn.uid("SEARCH", None, criteria)
|
|
231
|
+
if typ != "OK":
|
|
232
|
+
raise IMAPError(f"SEARCH failed: {data}")
|
|
233
|
+
|
|
234
|
+
raw = data[0] or b""
|
|
235
|
+
uids = [int(x) for x in raw.split()]
|
|
236
|
+
|
|
237
|
+
self._search_cache[cache_key] = uids
|
|
238
|
+
return uids
|
|
239
|
+
|
|
240
|
+
return self._run_with_conn(_impl)
|
|
241
|
+
|
|
242
|
+
def search_page_cached(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
mailbox: str,
|
|
246
|
+
query: IMAPQuery,
|
|
247
|
+
page_size: int = 50,
|
|
248
|
+
before_uid: Optional[int] = None,
|
|
249
|
+
after_uid: Optional[int] = None,
|
|
250
|
+
refresh: bool = False,
|
|
251
|
+
) -> PagedSearchResult:
|
|
252
|
+
if before_uid is not None and after_uid is not None:
|
|
253
|
+
raise ValueError("Cannot specify both before_uid and after_uid")
|
|
254
|
+
|
|
255
|
+
criteria = query.build() or "ALL"
|
|
256
|
+
cache_key = (mailbox, criteria)
|
|
257
|
+
|
|
258
|
+
with self._lock:
|
|
259
|
+
uids = None if refresh else self._search_cache.get(cache_key)
|
|
260
|
+
|
|
261
|
+
if uids is None:
|
|
262
|
+
uids = self.refresh_search_cache(mailbox=mailbox, query=query)
|
|
263
|
+
|
|
264
|
+
if not uids:
|
|
265
|
+
return PagedSearchResult(refs=[], total=0, has_next=False, has_prev=False)
|
|
266
|
+
|
|
267
|
+
uids_sorted = uids # ascending old->new
|
|
268
|
+
total_matches = len(uids_sorted)
|
|
269
|
+
|
|
270
|
+
if before_uid is not None:
|
|
271
|
+
idx = bisect_left(uids_sorted, before_uid)
|
|
272
|
+
end = idx
|
|
273
|
+
start = max(0, end - page_size)
|
|
274
|
+
elif after_uid is not None:
|
|
275
|
+
idx = bisect_right(uids_sorted, after_uid)
|
|
276
|
+
start = idx
|
|
277
|
+
end = min(len(uids_sorted), start + page_size)
|
|
278
|
+
else:
|
|
279
|
+
end = len(uids_sorted)
|
|
280
|
+
start = max(0, end - page_size)
|
|
281
|
+
|
|
282
|
+
if start >= end:
|
|
283
|
+
return PagedSearchResult(refs=[], total=total_matches, has_next=False, has_prev=False)
|
|
284
|
+
|
|
285
|
+
page_uids_asc = uids_sorted[start:end]
|
|
286
|
+
page_uids_desc = list(reversed(page_uids_asc))
|
|
287
|
+
|
|
288
|
+
refs = [EmailRef(uid=uid, mailbox=mailbox) for uid in page_uids_desc]
|
|
289
|
+
|
|
290
|
+
oldest_uid = page_uids_asc[0]
|
|
291
|
+
newest_uid = page_uids_asc[-1]
|
|
292
|
+
|
|
293
|
+
has_older = start > 0
|
|
294
|
+
has_newer = end < len(uids_sorted)
|
|
295
|
+
|
|
296
|
+
return PagedSearchResult(
|
|
297
|
+
refs=refs,
|
|
298
|
+
next_before_uid=oldest_uid if has_older else None,
|
|
299
|
+
prev_after_uid=newest_uid if has_newer else None,
|
|
300
|
+
newest_uid=newest_uid,
|
|
301
|
+
oldest_uid=oldest_uid,
|
|
302
|
+
total=total_matches,
|
|
303
|
+
has_next=has_older,
|
|
304
|
+
has_prev=has_newer,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def search(self, *, mailbox: str, query: IMAPQuery, limit: int = 50) -> List[EmailRef]:
|
|
308
|
+
page = self.search_page_cached(
|
|
309
|
+
mailbox=mailbox,
|
|
310
|
+
query=query,
|
|
311
|
+
page_size=limit,
|
|
312
|
+
refresh=True,
|
|
313
|
+
)
|
|
314
|
+
return page.refs
|
|
315
|
+
|
|
316
|
+
# -----------------------
|
|
317
|
+
# FETCH helpers
|
|
318
|
+
# -----------------------
|
|
319
|
+
|
|
320
|
+
def _fetch_section_mime_and_body(
|
|
321
|
+
self, conn: imaplib.IMAP4, *, uid: int, section: str
|
|
322
|
+
) -> Tuple[Optional[bytes], Optional[bytes]]:
|
|
323
|
+
want = f"(UID BODY.PEEK[{section}.MIME] BODY.PEEK[{section}])"
|
|
324
|
+
typ, data = conn.uid("FETCH", str(uid), want)
|
|
325
|
+
if typ != "OK":
|
|
326
|
+
raise IMAPError(f"FETCH body section failed uid={uid}: {data}")
|
|
327
|
+
|
|
328
|
+
mime_bytes: Optional[bytes] = None
|
|
329
|
+
body_bytes: Optional[bytes] = None
|
|
330
|
+
|
|
331
|
+
for piece in iter_fetch_pieces(data or []):
|
|
332
|
+
sec_mime = match_section_mime(piece.meta)
|
|
333
|
+
sec_body = match_section_body(piece.meta)
|
|
334
|
+
|
|
335
|
+
if piece.payload is None:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if sec_mime:
|
|
339
|
+
mime_bytes = piece.payload
|
|
340
|
+
elif sec_body:
|
|
341
|
+
body_bytes = piece.payload
|
|
342
|
+
|
|
343
|
+
return mime_bytes, body_bytes
|
|
344
|
+
|
|
345
|
+
def _decode_section(self, *, mime_bytes: Optional[bytes], body_bytes: Optional[bytes]) -> str:
|
|
346
|
+
if not body_bytes:
|
|
347
|
+
return ""
|
|
348
|
+
if not mime_bytes:
|
|
349
|
+
try:
|
|
350
|
+
return body_bytes.decode("utf-8", errors="replace")
|
|
351
|
+
except Exception:
|
|
352
|
+
return body_bytes.decode("latin-1", errors="replace")
|
|
353
|
+
|
|
354
|
+
msg = BytesParser(policy=default_policy).parsebytes(mime_bytes)
|
|
355
|
+
return decode_body_chunk(body_bytes, msg)
|
|
356
|
+
|
|
357
|
+
# -----------------------
|
|
358
|
+
# FETCH full message (headers + best text/html via BODYSTRUCTURE)
|
|
359
|
+
# -----------------------
|
|
360
|
+
|
|
361
|
+
def fetch(
|
|
362
|
+
self, refs: Sequence[EmailRef], *, include_attachment_meta: bool = False
|
|
363
|
+
) -> List[EmailMessage]:
|
|
364
|
+
if not refs:
|
|
365
|
+
return []
|
|
366
|
+
|
|
367
|
+
mailbox = self._assert_same_mailbox(refs, "fetch")
|
|
368
|
+
required_uids = {r.uid for r in refs}
|
|
369
|
+
|
|
370
|
+
def _impl(conn: imaplib.IMAP4) -> List[EmailMessage]:
|
|
371
|
+
self._ensure_selected(conn, mailbox, readonly=True)
|
|
372
|
+
|
|
373
|
+
uid_str = ",".join(str(r.uid) for r in refs)
|
|
374
|
+
attrs = "(UID INTERNALDATE BODYSTRUCTURE BODY.PEEK[HEADER])"
|
|
375
|
+
typ, data = conn.uid("FETCH", uid_str, attrs)
|
|
376
|
+
if typ != "OK":
|
|
377
|
+
raise IMAPError(f"FETCH failed: {data}")
|
|
378
|
+
if not data:
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
# Collect per-UID: headers bytes, internaldate str, bodystructure str
|
|
382
|
+
partial: Dict[int, Dict[str, object]] = {}
|
|
383
|
+
current_uid: Optional[int] = None
|
|
384
|
+
|
|
385
|
+
for piece in iter_fetch_pieces(data):
|
|
386
|
+
uid = parse_uid(piece.meta)
|
|
387
|
+
if uid is not None:
|
|
388
|
+
current_uid = uid if uid in required_uids else None
|
|
389
|
+
|
|
390
|
+
if current_uid is None:
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
bucket = partial.setdefault(
|
|
394
|
+
current_uid,
|
|
395
|
+
{"headers": None, "internaldate": None, "bodystructure": None},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
internal = parse_internaldate(piece.meta)
|
|
399
|
+
if internal:
|
|
400
|
+
bucket["internaldate"] = internal
|
|
401
|
+
|
|
402
|
+
if has_header_peek(piece.meta) and piece.payload is not None:
|
|
403
|
+
bucket["headers"] = piece.payload
|
|
404
|
+
|
|
405
|
+
bs = extract_bodystructure_from_fetch_meta(piece.meta)
|
|
406
|
+
if bs:
|
|
407
|
+
bucket["bodystructure"] = bs
|
|
408
|
+
|
|
409
|
+
out: List[EmailMessage] = []
|
|
410
|
+
for r in refs:
|
|
411
|
+
info = partial.get(r.uid)
|
|
412
|
+
if not info:
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
header_bytes = info.get("headers") or b""
|
|
416
|
+
internaldate_raw = info.get("internaldate")
|
|
417
|
+
bs_raw = info.get("bodystructure")
|
|
418
|
+
|
|
419
|
+
text = ""
|
|
420
|
+
html = ""
|
|
421
|
+
attachment_metas: List[AttachmentMeta] = []
|
|
422
|
+
|
|
423
|
+
if isinstance(bs_raw, str) and bs_raw:
|
|
424
|
+
try:
|
|
425
|
+
tree = parse_bodystructure(bs_raw)
|
|
426
|
+
text_parts, atts = extract_text_and_attachments(tree)
|
|
427
|
+
plain_ref, html_ref = pick_best_text_parts(text_parts)
|
|
428
|
+
|
|
429
|
+
if include_attachment_meta:
|
|
430
|
+
attachment_metas = atts
|
|
431
|
+
|
|
432
|
+
if plain_ref is not None:
|
|
433
|
+
mime_b, body_b = self._fetch_section_mime_and_body(
|
|
434
|
+
conn, uid=r.uid, section=plain_ref.part
|
|
435
|
+
)
|
|
436
|
+
text = self._decode_section(mime_bytes=mime_b, body_bytes=body_b)
|
|
437
|
+
|
|
438
|
+
if html_ref is not None:
|
|
439
|
+
mime_b, body_b = self._fetch_section_mime_and_body(
|
|
440
|
+
conn, uid=r.uid, section=html_ref.part
|
|
441
|
+
)
|
|
442
|
+
html = self._decode_section(mime_bytes=mime_b, body_bytes=body_b)
|
|
443
|
+
|
|
444
|
+
if html and attachment_metas:
|
|
445
|
+
|
|
446
|
+
html = inline_cids_as_data_uris(
|
|
447
|
+
conn=conn,
|
|
448
|
+
uid=r.uid,
|
|
449
|
+
html=html,
|
|
450
|
+
attachment_metas=attachment_metas,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
except Exception:
|
|
454
|
+
# Best-effort: headers still returned; bodies left empty.
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
msg = parse_headers_and_bodies(
|
|
458
|
+
r,
|
|
459
|
+
header_bytes,
|
|
460
|
+
text=text,
|
|
461
|
+
html=html,
|
|
462
|
+
attachments=attachment_metas if include_attachment_meta else [],
|
|
463
|
+
internaldate_raw=(
|
|
464
|
+
internaldate_raw if isinstance(internaldate_raw, str) else None
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
out.append(msg)
|
|
468
|
+
|
|
469
|
+
return out
|
|
470
|
+
|
|
471
|
+
return self._run_with_conn(_impl)
|
|
472
|
+
|
|
473
|
+
# -----------------------
|
|
474
|
+
# FETCH overview
|
|
475
|
+
# -----------------------
|
|
476
|
+
|
|
477
|
+
def fetch_overview(self, refs: Sequence[EmailRef]) -> List[EmailOverview]:
|
|
478
|
+
if not refs:
|
|
479
|
+
return []
|
|
480
|
+
mailbox = self._assert_same_mailbox(refs, "fetch_overview")
|
|
481
|
+
|
|
482
|
+
def _impl(conn: imaplib.IMAP4) -> List[EmailOverview]:
|
|
483
|
+
self._ensure_selected(conn, mailbox, readonly=True)
|
|
484
|
+
|
|
485
|
+
uid_str = ",".join(str(r.uid) for r in refs)
|
|
486
|
+
attrs = (
|
|
487
|
+
"(UID FLAGS INTERNALDATE "
|
|
488
|
+
"BODY.PEEK[HEADER.FIELDS (From To Subject Date Message-ID Content-Type Content-Transfer-Encoding)])"
|
|
489
|
+
)
|
|
490
|
+
typ, data = conn.uid("FETCH", uid_str, attrs)
|
|
491
|
+
if typ != "OK":
|
|
492
|
+
raise IMAPError(f"FETCH overview failed: {data}")
|
|
493
|
+
if not data:
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
partial: Dict[int, Dict[str, object]] = {}
|
|
497
|
+
current_uid: Optional[int] = None
|
|
498
|
+
|
|
499
|
+
for piece in iter_fetch_pieces(data):
|
|
500
|
+
uid = parse_uid(piece.meta)
|
|
501
|
+
if uid is not None:
|
|
502
|
+
current_uid = uid
|
|
503
|
+
|
|
504
|
+
if current_uid is None:
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
bucket = partial.setdefault(
|
|
508
|
+
current_uid,
|
|
509
|
+
{"flags": set(), "headers": None, "internaldate": None},
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
bucket["flags"] = parse_flags(piece.meta) or bucket["flags"]
|
|
513
|
+
|
|
514
|
+
internal = parse_internaldate(piece.meta)
|
|
515
|
+
if internal:
|
|
516
|
+
bucket["internaldate"] = internal
|
|
517
|
+
|
|
518
|
+
if piece.payload is not None:
|
|
519
|
+
bucket["headers"] = piece.payload
|
|
520
|
+
|
|
521
|
+
overviews: List[EmailOverview] = []
|
|
522
|
+
for r in refs:
|
|
523
|
+
info = partial.get(r.uid)
|
|
524
|
+
if not info:
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
flags = set(info["flags"]) if isinstance(info["flags"], set) else set()
|
|
528
|
+
header_bytes = info.get("headers") or b""
|
|
529
|
+
internaldate_raw = info.get("internaldate")
|
|
530
|
+
|
|
531
|
+
overviews.append(
|
|
532
|
+
parse_overview(
|
|
533
|
+
r,
|
|
534
|
+
flags,
|
|
535
|
+
header_bytes,
|
|
536
|
+
internaldate_raw=(
|
|
537
|
+
internaldate_raw if isinstance(internaldate_raw, str) else None
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
return overviews
|
|
543
|
+
|
|
544
|
+
return self._run_with_conn(_impl)
|
|
545
|
+
|
|
546
|
+
# -----------------------
|
|
547
|
+
# Attachment fetch
|
|
548
|
+
# -----------------------
|
|
549
|
+
|
|
550
|
+
def fetch_attachment(self, ref: EmailRef, attachment_part: str) -> bytes:
|
|
551
|
+
mailbox = ref.mailbox
|
|
552
|
+
uid = ref.uid
|
|
553
|
+
part = attachment_part
|
|
554
|
+
|
|
555
|
+
def _impl(conn: imaplib.IMAP4) -> bytes:
|
|
556
|
+
self._ensure_selected(conn, mailbox, readonly=True)
|
|
557
|
+
return fetch_part_bytes(conn, uid=uid, part=part)
|
|
558
|
+
|
|
559
|
+
return self._run_with_conn(_impl)
|
|
560
|
+
|
|
561
|
+
# -----------------------
|
|
562
|
+
# Mutations
|
|
563
|
+
# -----------------------
|
|
564
|
+
|
|
565
|
+
def append(
|
|
566
|
+
self,
|
|
567
|
+
mailbox: str,
|
|
568
|
+
msg: PyEmailMessage,
|
|
569
|
+
*,
|
|
570
|
+
flags: Optional[Set[str]] = None,
|
|
571
|
+
) -> EmailRef:
|
|
572
|
+
def _impl(conn: imaplib.IMAP4) -> EmailRef:
|
|
573
|
+
self._ensure_selected(conn, mailbox, readonly=False)
|
|
574
|
+
|
|
575
|
+
flags_arg = "(" + " ".join(sorted(flags)) + ")" if flags else None
|
|
576
|
+
date_time = imaplib.Time2Internaldate(time.time())
|
|
577
|
+
raw_bytes = msg.as_bytes()
|
|
578
|
+
imap_mailbox = self._format_mailbox_arg(mailbox)
|
|
579
|
+
|
|
580
|
+
typ, data = conn.append(imap_mailbox, flags_arg, date_time, raw_bytes)
|
|
581
|
+
if typ != "OK":
|
|
582
|
+
raise IMAPError(f"APPEND to {mailbox!r} failed: {data}")
|
|
583
|
+
|
|
584
|
+
uid: Optional[int] = None
|
|
585
|
+
if data and data[0]:
|
|
586
|
+
resp = (
|
|
587
|
+
data[0].decode(errors="ignore") if isinstance(data[0], bytes) else str(data[0])
|
|
588
|
+
)
|
|
589
|
+
m = re.search(r"APPENDUID\s+\d+\s+(\d+)", resp)
|
|
590
|
+
if m:
|
|
591
|
+
uid = int(m.group(1))
|
|
592
|
+
|
|
593
|
+
if uid is None:
|
|
594
|
+
typ_search, data_search = conn.uid("SEARCH", None, "ALL")
|
|
595
|
+
if typ_search == "OK" and data_search and data_search[0]:
|
|
596
|
+
all_uids = [int(x) for x in data_search[0].split() if x.strip()]
|
|
597
|
+
uid = max(all_uids) if all_uids else None
|
|
598
|
+
|
|
599
|
+
if uid is None:
|
|
600
|
+
raise IMAPError("APPEND succeeded but could not determine UID")
|
|
601
|
+
|
|
602
|
+
return EmailRef(uid=uid, mailbox=mailbox)
|
|
603
|
+
|
|
604
|
+
ref = self._run_with_conn(_impl) # type: ignore[assignment]
|
|
605
|
+
self._invalidate_search_cache(mailbox)
|
|
606
|
+
return ref
|
|
607
|
+
|
|
608
|
+
def add_flags(self, refs: Sequence[EmailRef], *, flags: Set[str]) -> None:
|
|
609
|
+
self._store(refs, mode="+FLAGS", flags=flags)
|
|
610
|
+
|
|
611
|
+
def remove_flags(self, refs: Sequence[EmailRef], *, flags: Set[str]) -> None:
|
|
612
|
+
self._store(refs, mode="-FLAGS", flags=flags)
|
|
613
|
+
|
|
614
|
+
def _store(self, refs: Sequence[EmailRef], *, mode: str, flags: Set[str]) -> None:
|
|
615
|
+
if not refs:
|
|
616
|
+
return
|
|
617
|
+
mailbox = self._assert_same_mailbox(refs, "_store")
|
|
618
|
+
|
|
619
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
620
|
+
self._ensure_selected(conn, mailbox, readonly=False)
|
|
621
|
+
uids = ",".join(str(r.uid) for r in refs)
|
|
622
|
+
flag_list = "(" + " ".join(sorted(flags)) + ")"
|
|
623
|
+
typ, data = conn.uid("STORE", uids, mode, flag_list)
|
|
624
|
+
if typ != "OK":
|
|
625
|
+
raise IMAPError(f"STORE failed: {data}")
|
|
626
|
+
|
|
627
|
+
self._run_with_conn(_impl)
|
|
628
|
+
# flags can change search results depending on query criteria
|
|
629
|
+
self._invalidate_search_cache(mailbox)
|
|
630
|
+
|
|
631
|
+
def expunge(self, mailbox: str = "INBOX") -> None:
|
|
632
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
633
|
+
self._ensure_selected(conn, mailbox, readonly=False)
|
|
634
|
+
typ, data = conn.expunge()
|
|
635
|
+
if typ != "OK":
|
|
636
|
+
raise IMAPError(f"EXPUNGE failed: {data}")
|
|
637
|
+
|
|
638
|
+
self._run_with_conn(_impl)
|
|
639
|
+
self._invalidate_search_cache(mailbox)
|
|
640
|
+
|
|
641
|
+
# -----------------------
|
|
642
|
+
# Mailboxes
|
|
643
|
+
# -----------------------
|
|
644
|
+
|
|
645
|
+
def list_mailboxes(self) -> List[str]:
|
|
646
|
+
def _impl(conn: imaplib.IMAP4) -> List[str]:
|
|
647
|
+
typ, data = conn.list()
|
|
648
|
+
if typ != "OK":
|
|
649
|
+
raise IMAPError(f"LIST failed: {data}")
|
|
650
|
+
|
|
651
|
+
mailboxes: List[str] = []
|
|
652
|
+
for raw in data or []:
|
|
653
|
+
if not raw:
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
flags = self._parse_list_flags(raw)
|
|
657
|
+
if r"\NOSELECT" in flags:
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
name = parse_list_mailbox_name(raw)
|
|
661
|
+
if name is not None:
|
|
662
|
+
mailboxes.append(name)
|
|
663
|
+
|
|
664
|
+
return mailboxes
|
|
665
|
+
|
|
666
|
+
return self._run_with_conn(_impl)
|
|
667
|
+
|
|
668
|
+
def mailbox_status(self, mailbox: str = "INBOX") -> Dict[str, int]:
|
|
669
|
+
def _impl(conn: imaplib.IMAP4) -> Dict[str, int]:
|
|
670
|
+
imap_mailbox = self._format_mailbox_arg(mailbox)
|
|
671
|
+
|
|
672
|
+
typ, data = conn.status(
|
|
673
|
+
imap_mailbox,
|
|
674
|
+
"(MESSAGES UNSEEN UIDNEXT UIDVALIDITY HIGHESTMODSEQ)",
|
|
675
|
+
)
|
|
676
|
+
if typ != "OK":
|
|
677
|
+
raise IMAPError(f"STATUS {mailbox!r} failed: {data}")
|
|
678
|
+
if not data or not data[0]:
|
|
679
|
+
raise IMAPError(f"STATUS {mailbox!r} returned empty data")
|
|
680
|
+
|
|
681
|
+
raw = data[0]
|
|
682
|
+
s = raw.decode(errors="ignore") if isinstance(raw, bytes) else str(raw)
|
|
683
|
+
|
|
684
|
+
start = s.find("(")
|
|
685
|
+
end = s.rfind(")")
|
|
686
|
+
if start == -1 or end == -1 or end <= start:
|
|
687
|
+
raise IMAPError(f"Unexpected STATUS response: {s!r}")
|
|
688
|
+
|
|
689
|
+
payload = s[start + 1 : end]
|
|
690
|
+
tokens = payload.split()
|
|
691
|
+
|
|
692
|
+
status: Dict[str, int] = {}
|
|
693
|
+
|
|
694
|
+
for i in range(0, len(tokens) - 1, 2):
|
|
695
|
+
key = tokens[i].upper()
|
|
696
|
+
try:
|
|
697
|
+
val = int(tokens[i + 1])
|
|
698
|
+
except ValueError:
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
if key == "MESSAGES":
|
|
702
|
+
status["messages"] = val
|
|
703
|
+
elif key == "UNSEEN":
|
|
704
|
+
status["unseen"] = val
|
|
705
|
+
elif key == "UIDNEXT":
|
|
706
|
+
status["uidnext"] = val
|
|
707
|
+
elif key == "UIDVALIDITY":
|
|
708
|
+
status["uidvalidity"] = val
|
|
709
|
+
elif key == "HIGHESTMODSEQ":
|
|
710
|
+
status["highestmodseq"] = val
|
|
711
|
+
else:
|
|
712
|
+
status[key.lower()] = val
|
|
713
|
+
|
|
714
|
+
return status
|
|
715
|
+
|
|
716
|
+
return self._run_with_conn(_impl)
|
|
717
|
+
|
|
718
|
+
def move(self, refs: Sequence[EmailRef], *, src_mailbox: str, dst_mailbox: str) -> None:
|
|
719
|
+
if not refs:
|
|
720
|
+
return
|
|
721
|
+
for r in refs:
|
|
722
|
+
if r.mailbox != src_mailbox:
|
|
723
|
+
raise IMAPError("All EmailRef.mailbox must match src_mailbox for move()")
|
|
724
|
+
|
|
725
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
726
|
+
self._ensure_selected(conn, src_mailbox, readonly=False)
|
|
727
|
+
|
|
728
|
+
uids = ",".join(str(r.uid) for r in refs)
|
|
729
|
+
dst_arg = self._format_mailbox_arg(dst_mailbox)
|
|
730
|
+
|
|
731
|
+
typ, data = conn.uid("MOVE", uids, dst_arg)
|
|
732
|
+
if typ == "OK":
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
typ_copy, data_copy = conn.uid("COPY", uids, dst_arg)
|
|
736
|
+
if typ_copy != "OK":
|
|
737
|
+
raise IMAPError(f"COPY (for MOVE fallback) failed: {data_copy}")
|
|
738
|
+
|
|
739
|
+
typ_store, data_store = conn.uid("STORE", uids, "+FLAGS.SILENT", r"(\Deleted)")
|
|
740
|
+
if typ_store != "OK":
|
|
741
|
+
raise IMAPError(f"STORE +FLAGS.SILENT \\Deleted failed: {data_store}")
|
|
742
|
+
|
|
743
|
+
typ_expunge, data_expunge = conn.expunge()
|
|
744
|
+
if typ_expunge != "OK":
|
|
745
|
+
raise IMAPError(f"EXPUNGE (after MOVE fallback) failed: {data_expunge}")
|
|
746
|
+
|
|
747
|
+
self._run_with_conn(_impl)
|
|
748
|
+
self._invalidate_search_cache(src_mailbox)
|
|
749
|
+
self._invalidate_search_cache(dst_mailbox)
|
|
750
|
+
|
|
751
|
+
def copy(self, refs: Sequence[EmailRef], *, src_mailbox: str, dst_mailbox: str) -> None:
|
|
752
|
+
if not refs:
|
|
753
|
+
return
|
|
754
|
+
for r in refs:
|
|
755
|
+
if r.mailbox != src_mailbox:
|
|
756
|
+
raise IMAPError("All EmailRef.mailbox must match src_mailbox for copy()")
|
|
757
|
+
|
|
758
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
759
|
+
self._ensure_selected(conn, src_mailbox, readonly=False)
|
|
760
|
+
|
|
761
|
+
uids = ",".join(str(r.uid) for r in refs)
|
|
762
|
+
dst_arg = self._format_mailbox_arg(dst_mailbox)
|
|
763
|
+
typ, data = conn.uid("COPY", uids, dst_arg)
|
|
764
|
+
if typ != "OK":
|
|
765
|
+
raise IMAPError(f"COPY failed: {data}")
|
|
766
|
+
|
|
767
|
+
self._run_with_conn(_impl)
|
|
768
|
+
self._invalidate_search_cache(dst_mailbox)
|
|
769
|
+
|
|
770
|
+
def create_mailbox(self, name: str) -> None:
|
|
771
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
772
|
+
imap_name = self._format_mailbox_arg(name)
|
|
773
|
+
typ, data = conn.create(imap_name)
|
|
774
|
+
if typ != "OK":
|
|
775
|
+
raise IMAPError(f"CREATE {name!r} failed: {data}")
|
|
776
|
+
|
|
777
|
+
self._run_with_conn(_impl)
|
|
778
|
+
self._invalidate_search_cache()
|
|
779
|
+
|
|
780
|
+
def delete_mailbox(self, name: str) -> None:
|
|
781
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
782
|
+
imap_name = self._format_mailbox_arg(name)
|
|
783
|
+
typ, data = conn.delete(imap_name)
|
|
784
|
+
if typ != "OK":
|
|
785
|
+
raise IMAPError(f"DELETE {name!r} failed: {data}")
|
|
786
|
+
|
|
787
|
+
self._run_with_conn(_impl)
|
|
788
|
+
self._invalidate_search_cache()
|
|
789
|
+
|
|
790
|
+
def ping(self) -> None:
|
|
791
|
+
def _impl(conn: imaplib.IMAP4) -> None:
|
|
792
|
+
typ, data = conn.noop()
|
|
793
|
+
if typ != "OK":
|
|
794
|
+
raise IMAPError(f"NOOP failed: {data}")
|
|
795
|
+
|
|
796
|
+
self._run_with_conn(_impl)
|
|
797
|
+
|
|
798
|
+
def close(self) -> None:
|
|
799
|
+
with self._lock:
|
|
800
|
+
self._reset_conn()
|
|
801
|
+
|
|
802
|
+
def __enter__(self) -> IMAPClient:
|
|
803
|
+
return self
|
|
804
|
+
|
|
805
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
806
|
+
self.close()
|