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.
Files changed (67) hide show
  1. openmail/__init__.py +6 -0
  2. openmail/assistants/__init__.py +35 -0
  3. openmail/assistants/classify_emails.py +83 -0
  4. openmail/assistants/compose_email.py +43 -0
  5. openmail/assistants/detect_phishing_for_email.py +61 -0
  6. openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
  7. openmail/assistants/extract_tasks_from_emails.py +126 -0
  8. openmail/assistants/generate_follow_up_for_email.py +54 -0
  9. openmail/assistants/natural_language_query.py +699 -0
  10. openmail/assistants/prioritize_emails.py +89 -0
  11. openmail/assistants/reply.py +58 -0
  12. openmail/assistants/reply_suggestions.py +46 -0
  13. openmail/assistants/rewrite_email.py +50 -0
  14. openmail/assistants/summarize_attachments_for_email.py +101 -0
  15. openmail/assistants/summarize_thread_emails.py +55 -0
  16. openmail/assistants/summary.py +44 -0
  17. openmail/assistants/summary_multi.py +57 -0
  18. openmail/assistants/translate_email.py +54 -0
  19. openmail/auth/__init__.py +6 -0
  20. openmail/auth/base.py +34 -0
  21. openmail/auth/no_auth.py +19 -0
  22. openmail/auth/oauth2.py +58 -0
  23. openmail/auth/password.py +26 -0
  24. openmail/config.py +26 -0
  25. openmail/email_assistant.py +418 -0
  26. openmail/email_manager.py +777 -0
  27. openmail/email_query.py +279 -0
  28. openmail/errors.py +16 -0
  29. openmail/imap/__init__.py +5 -0
  30. openmail/imap/attachment_parts.py +55 -0
  31. openmail/imap/bodystructure.py +296 -0
  32. openmail/imap/client.py +806 -0
  33. openmail/imap/fetch_response.py +115 -0
  34. openmail/imap/inline_cid.py +106 -0
  35. openmail/imap/pagination.py +16 -0
  36. openmail/imap/parser.py +298 -0
  37. openmail/imap/query.py +233 -0
  38. openmail/llm/__init__.py +3 -0
  39. openmail/llm/claude.py +35 -0
  40. openmail/llm/costs.py +108 -0
  41. openmail/llm/gemini.py +34 -0
  42. openmail/llm/gpt.py +33 -0
  43. openmail/llm/groq.py +36 -0
  44. openmail/llm/model.py +126 -0
  45. openmail/llm/xai.py +35 -0
  46. openmail/logger.py +20 -0
  47. openmail/models/__init__.py +20 -0
  48. openmail/models/attachment.py +128 -0
  49. openmail/models/message.py +113 -0
  50. openmail/models/subscription.py +45 -0
  51. openmail/models/task.py +24 -0
  52. openmail/py.typed +0 -0
  53. openmail/smtp/__init__.py +7 -0
  54. openmail/smtp/builder.py +41 -0
  55. openmail/smtp/client.py +218 -0
  56. openmail/smtp/templates.py +16 -0
  57. openmail/subscription/__init__.py +7 -0
  58. openmail/subscription/detector.py +58 -0
  59. openmail/subscription/parser.py +32 -0
  60. openmail/subscription/service.py +237 -0
  61. openmail/types.py +30 -0
  62. openmail/utils/__init__.py +39 -0
  63. openmail/utils/utils.py +295 -0
  64. openmail-0.1.5.dist-info/METADATA +180 -0
  65. openmail-0.1.5.dist-info/RECORD +67 -0
  66. openmail-0.1.5.dist-info/WHEEL +4 -0
  67. openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
@@ -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()