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,777 @@
1
+ from __future__ import annotations
2
+
3
+ import html as _html
4
+ from dataclasses import dataclass
5
+ from email.message import EmailMessage as PyEmailMessage
6
+ from typing import Dict, List, Optional, Sequence, Set
7
+
8
+ from openmail.imap import IMAPClient, PagedSearchResult
9
+ from openmail.models import (
10
+ Attachment,
11
+ EmailMessage,
12
+ EmailOverview,
13
+ UnsubscribeActionResult,
14
+ UnsubscribeCandidate,
15
+ )
16
+ from openmail.smtp import SMTPClient
17
+ from openmail.subscription import SubscriptionDetector, SubscriptionService
18
+ from openmail.types import EmailRef, SendResult
19
+ from openmail.utils import (
20
+ build_references,
21
+ dedup_addrs,
22
+ ensure_forward_subject,
23
+ ensure_reply_subject,
24
+ get_header,
25
+ parse_addrs,
26
+ quote_forward_html,
27
+ quote_forward_text,
28
+ quote_original_reply_html,
29
+ quote_original_reply_text,
30
+ remove_addr,
31
+ )
32
+
33
+ from .email_query import EmailQuery
34
+
35
+ SEEN = r"\Seen"
36
+ ANSWERED = r"\Answered"
37
+ FLAGGED = r"\Flagged"
38
+ DELETED = r"\Deleted"
39
+ DRAFT = r"\Draft"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class EmailManager:
44
+ smtp: SMTPClient
45
+ imap: IMAPClient
46
+
47
+ def _set_body(
48
+ self,
49
+ msg: PyEmailMessage,
50
+ text: Optional[str],
51
+ html: Optional[str],
52
+ ) -> None:
53
+ """
54
+ Set message body as:
55
+ - text only if no html
56
+ - multipart/alternative if both text and html are provided
57
+ - html-only if only html is provided
58
+ """
59
+ if html is not None:
60
+ if text:
61
+ msg.set_content(text)
62
+ msg.add_alternative(html, subtype="html")
63
+ else:
64
+ msg.set_content(html, subtype="html")
65
+ else:
66
+ msg.set_content(text or "")
67
+
68
+ def _add_attachment(
69
+ self,
70
+ msg: PyEmailMessage,
71
+ attachments: Optional[Sequence[Attachment]],
72
+ ) -> None:
73
+ """
74
+ Add attachments to the email message.
75
+ """
76
+ if not attachments:
77
+ return
78
+
79
+ for att in attachments:
80
+ content_type = att.content_type or "application/octet-stream"
81
+ maintype, _, subtype = content_type.partition("/")
82
+ data = att.data
83
+ filename = att.filename
84
+ if data is not None:
85
+ msg.add_attachment(
86
+ data,
87
+ maintype=maintype or "application",
88
+ subtype=subtype or "octet-stream",
89
+ filename=filename,
90
+ )
91
+
92
+ def _extract_envelope_recipients(self, msg: PyEmailMessage) -> list[str]:
93
+ addr_headers = []
94
+ addr_headers.extend(msg.get_all("To", []))
95
+ addr_headers.extend(msg.get_all("Cc", []))
96
+ addr_headers.extend(msg.get_all("Bcc", []))
97
+
98
+ pairs = parse_addrs(*addr_headers)
99
+ # simple dedup by lowercase address
100
+ seen = set()
101
+ result: list[str] = []
102
+ for _, addr in pairs:
103
+ norm = addr.strip().lower()
104
+ if norm and norm not in seen:
105
+ seen.add(norm)
106
+ result.append(addr)
107
+ return result
108
+
109
+ def fetch_message_by_ref(
110
+ self,
111
+ ref: EmailRef,
112
+ *,
113
+ include_attachment_meta: bool = False,
114
+ ) -> EmailMessage:
115
+ """
116
+ Fetch exactly one EmailMessage by EmailRef.
117
+ """
118
+ msgs = self.imap.fetch([ref], include_attachment_meta=include_attachment_meta)
119
+ if not msgs:
120
+ raise ValueError(f"No message found for ref: {ref!r}")
121
+ return msgs[0]
122
+
123
+ def fetch_attachment_by_ref_and_meta(
124
+ self,
125
+ ref: EmailRef,
126
+ attachment_part: str,
127
+ ) -> bytes:
128
+ """
129
+ Fetch exactly one EmailMessage by EmailRef.
130
+ """
131
+ attachment = self.imap.fetch_attachment(ref, attachment_part)
132
+ if not attachment:
133
+ raise ValueError(f"No attachment found for ref: {ref!r} and part: {attachment_part!r}")
134
+ return attachment
135
+
136
+ def fetch_messages_by_multi_refs(
137
+ self,
138
+ refs: Sequence[EmailRef],
139
+ *,
140
+ include_attachment_meta: bool = False,
141
+ ) -> List[EmailMessage]:
142
+ """
143
+ Fetch multiple EmailMessage by EmailRef.
144
+ """
145
+ if not refs:
146
+ return []
147
+ return list(self.imap.fetch(refs, include_attachment_meta=include_attachment_meta))
148
+
149
+ def send(self, msg: PyEmailMessage) -> SendResult:
150
+ recipients = self._extract_envelope_recipients(msg)
151
+
152
+ if "Bcc" in msg:
153
+ del msg["Bcc"]
154
+
155
+ if not recipients:
156
+ raise ValueError("send(): no recipients found in To/Cc/Bcc")
157
+
158
+ return self.smtp.send(msg, recipients)
159
+
160
+ def compose(
161
+ self,
162
+ *,
163
+ subject: str,
164
+ to: Sequence[str],
165
+ from_addr: Optional[str] = None,
166
+ cc: Sequence[str] = (),
167
+ bcc: Sequence[str] = (),
168
+ text: Optional[str] = None,
169
+ html: Optional[str] = None,
170
+ attachments: Optional[Sequence[Attachment]] = None,
171
+ extra_headers: Optional[Dict[str, str]] = None,
172
+ ) -> PyEmailMessage:
173
+ """
174
+ Build a new outgoing email.
175
+
176
+ - subject, to, from_addr are the main headers
177
+ - text/html: plain-text and/or HTML bodies
178
+ - attachments: list of your Attachment models
179
+ - extra_headers: optional extra headers (e.g. Reply-To)
180
+ """
181
+
182
+ msg = PyEmailMessage()
183
+
184
+ if from_addr:
185
+ msg["From"] = from_addr
186
+ msg["To"] = ", ".join(to)
187
+ if cc:
188
+ msg["Cc"] = ", ".join(cc)
189
+ if bcc:
190
+ msg["Bcc"] = ", ".join(bcc)
191
+
192
+ msg["Subject"] = subject
193
+
194
+ if extra_headers:
195
+ for k, v in extra_headers.items():
196
+ if k.lower() in {"from", "to", "cc", "bcc", "subject"}:
197
+ continue
198
+ msg[k] = v
199
+
200
+ self._set_body(msg, text, html)
201
+ self._add_attachment(msg, attachments)
202
+
203
+ return msg
204
+
205
+ def compose_and_send(
206
+ self,
207
+ *,
208
+ subject: str,
209
+ to: Sequence[str],
210
+ from_addr: Optional[str] = None,
211
+ cc: Sequence[str] = (),
212
+ bcc: Sequence[str] = (),
213
+ text: Optional[str] = None,
214
+ html: Optional[str] = None,
215
+ attachments: Optional[Sequence[Attachment]] = None,
216
+ extra_headers: Optional[Dict[str, str]] = None,
217
+ ) -> SendResult:
218
+ """
219
+ Convenience wrapper: compose a new email and send it.
220
+ """
221
+
222
+ if not to and not cc and not bcc:
223
+ raise ValueError(
224
+ "compose_and_send(): at least one of to/cc/bcc must contain a recipient"
225
+ )
226
+
227
+ msg = self.compose(
228
+ subject=subject,
229
+ to=to,
230
+ from_addr=from_addr,
231
+ cc=cc,
232
+ bcc=bcc,
233
+ text=text,
234
+ html=html,
235
+ attachments=attachments,
236
+ extra_headers=extra_headers,
237
+ )
238
+ return self.send(msg)
239
+
240
+ def save_draft(
241
+ self,
242
+ *,
243
+ subject: str,
244
+ to: Sequence[str],
245
+ from_addr: Optional[str] = None,
246
+ cc: Sequence[str] = (),
247
+ bcc: Sequence[str] = (),
248
+ text: Optional[str] = None,
249
+ html: Optional[str] = None,
250
+ attachments: Optional[Sequence[Attachment]] = None,
251
+ extra_headers: Optional[Dict[str, str]] = None,
252
+ mailbox: str = "Drafts",
253
+ ) -> EmailRef:
254
+ """
255
+ Compose an email and save it to a Drafts mailbox without sending.
256
+ Returns EmailRef for later .send().
257
+ """
258
+ msg = self.compose(
259
+ subject=subject,
260
+ to=to,
261
+ from_addr=from_addr,
262
+ cc=cc,
263
+ bcc=bcc,
264
+ text=text,
265
+ html=html,
266
+ attachments=attachments,
267
+ extra_headers=extra_headers,
268
+ )
269
+ return self.imap.append(mailbox, msg, flags={DRAFT})
270
+
271
+ def reply(
272
+ self,
273
+ original: EmailMessage,
274
+ *,
275
+ text: str,
276
+ html: Optional[str] = None,
277
+ from_addr: Optional[str] = None,
278
+ quote_original: bool = False,
279
+ to: Optional[Sequence[str]] = None,
280
+ cc: Optional[Sequence[str]] = None,
281
+ bcc: Optional[Sequence[str]] = None,
282
+ subject: Optional[str] = None,
283
+ attachments: Optional[Sequence[Attachment]] = None,
284
+ extra_headers: Optional[Dict[str, str]] = None,
285
+ ) -> SendResult:
286
+ """
287
+ Reply to a single sender.
288
+
289
+ - If to/cc/bcc/subject/attachments are None, sensible defaults are derived
290
+ from `original`.
291
+ - If provided, they override the defaults but threading headers are still
292
+ managed here.
293
+ """
294
+ if to is None:
295
+ reply_to = get_header(original.headers, "Reply-To") or original.from_email
296
+ if not reply_to:
297
+ raise ValueError("reply(): original message has no Reply-To or From address")
298
+
299
+ to_pairs = parse_addrs(reply_to)
300
+ to_addrs = dedup_addrs(to_pairs)
301
+ if not to_addrs:
302
+ raise ValueError("reply(): could not parse any valid reply addresses")
303
+ else:
304
+ to_addrs = list(to)
305
+
306
+ cc_addrs = list(cc) if cc is not None else []
307
+ bcc_addrs = list(bcc) if bcc is not None else []
308
+
309
+ final_subject = subject or ensure_reply_subject(original.subject)
310
+
311
+ headers: Dict[str, str] = {}
312
+ orig_mid = original.message_id
313
+ if orig_mid:
314
+ headers["In-Reply-To"] = orig_mid
315
+ existing_refs = get_header(original.headers, "References")
316
+ headers["References"] = build_references(existing_refs, orig_mid)
317
+
318
+ if extra_headers:
319
+ headers.update(extra_headers)
320
+
321
+ if quote_original:
322
+ quoted_text = quote_original_reply_text(original)
323
+ text_body = text + "\n\n" + quoted_text if text else quoted_text
324
+
325
+ if html is not None:
326
+ quoted_html = quote_original_reply_html(original)
327
+ html_body = html + "<br><br>" + quoted_html
328
+ else:
329
+ html_body = None
330
+ else:
331
+ text_body = text
332
+ html_body = html
333
+
334
+ msg = self.compose(
335
+ subject=final_subject,
336
+ to=to_addrs,
337
+ from_addr=from_addr,
338
+ cc=cc_addrs,
339
+ bcc=bcc_addrs,
340
+ text=text_body,
341
+ html=html_body,
342
+ attachments=attachments,
343
+ extra_headers=headers or None,
344
+ )
345
+
346
+ return self.send(msg)
347
+
348
+ def reply_all(
349
+ self,
350
+ original: EmailMessage,
351
+ *,
352
+ text: str,
353
+ html: Optional[str] = None,
354
+ from_addr: Optional[str] = None,
355
+ quote_original: bool = False,
356
+ to: Optional[Sequence[str]] = None,
357
+ cc: Optional[Sequence[str]] = None,
358
+ bcc: Optional[Sequence[str]] = None,
359
+ subject: Optional[str] = None,
360
+ attachments: Optional[Sequence[Attachment]] = None,
361
+ extra_headers: Optional[Dict[str, str]] = None,
362
+ ) -> SendResult:
363
+ """
364
+ Reply to everyone.
365
+
366
+ - If to/cc/bcc are None, they are derived from original (Reply-To/From + To/Cc).
367
+ - If provided, we trust the UI values and do not recompute recipients.
368
+ """
369
+ # Recipients
370
+ if to is None and cc is None and bcc is None:
371
+ # Derive default reply-all recipients
372
+ primary = get_header(original.headers, "Reply-To") or original.from_email
373
+ primary_pairs = parse_addrs(primary) if primary else []
374
+
375
+ to_str = ", ".join(original.to) if original.to else ""
376
+ cc_str = ", ".join(original.cc) if original.cc else ""
377
+ others_pairs = parse_addrs(to_str, cc_str)
378
+
379
+ if from_addr:
380
+ primary_pairs = remove_addr(primary_pairs, from_addr)
381
+ others_pairs = remove_addr(others_pairs, from_addr)
382
+
383
+ primary_set = {addr.strip().lower() for _, addr in primary_pairs}
384
+ cc_pairs = [(n, a) for (n, a) in others_pairs if a.strip().lower() not in primary_set]
385
+
386
+ to_addrs = dedup_addrs(primary_pairs)
387
+ cc_addrs = dedup_addrs(cc_pairs)
388
+ bcc_addrs: List[str] = []
389
+ else:
390
+ to_addrs = list(to) if to is not None else []
391
+ cc_addrs = list(cc) if cc is not None else []
392
+ bcc_addrs = list(bcc) if bcc is not None else []
393
+
394
+ if not to_addrs:
395
+ raise ValueError("reply_all(): no primary recipients")
396
+
397
+ final_subject = subject or ensure_reply_subject(original.subject)
398
+
399
+ headers: Dict[str, str] = {}
400
+ orig_mid = original.message_id
401
+ if orig_mid:
402
+ headers["In-Reply-To"] = orig_mid
403
+ existing_refs = get_header(original.headers, "References")
404
+ headers["References"] = build_references(existing_refs, orig_mid)
405
+
406
+ if extra_headers:
407
+ headers.update(extra_headers)
408
+
409
+ if quote_original:
410
+ quoted_text = quote_original_reply_text(original)
411
+ text_body = text + "\n\n" + quoted_text if text else quoted_text
412
+
413
+ if html is not None:
414
+ quoted_html = quote_original_reply_html(original)
415
+ html_body = html + "<br><br>" + quoted_html
416
+ else:
417
+ html_body = None
418
+ else:
419
+ text_body = text
420
+ html_body = html
421
+
422
+ msg = self.compose(
423
+ subject=final_subject,
424
+ to=to_addrs,
425
+ from_addr=from_addr,
426
+ cc=cc_addrs,
427
+ bcc=bcc_addrs,
428
+ text=text_body,
429
+ html=html_body,
430
+ attachments=attachments,
431
+ extra_headers=headers or None,
432
+ )
433
+
434
+ return self.send(msg)
435
+
436
+ def forward(
437
+ self,
438
+ original: EmailMessage,
439
+ *,
440
+ to: Sequence[str],
441
+ text: Optional[str] = None,
442
+ html: Optional[str] = None,
443
+ from_addr: Optional[str] = None,
444
+ include_original: bool = False,
445
+ include_attachments: bool = True,
446
+ cc: Optional[Sequence[str]] = None,
447
+ bcc: Optional[Sequence[str]] = None,
448
+ subject: Optional[str] = None,
449
+ attachments: Optional[Sequence[Attachment]] = None,
450
+ extra_headers: Optional[Dict[str, str]] = None,
451
+ ) -> SendResult:
452
+ """
453
+ Forward an existing email.
454
+ """
455
+ if not to:
456
+ raise ValueError("forward(): 'to' must contain at least one recipient")
457
+
458
+ text_parts: List[str] = []
459
+ if text:
460
+ text_parts.append(text)
461
+ if include_original:
462
+ text_parts.append(quote_forward_text(original))
463
+ text_body = "\n".join(text_parts)
464
+
465
+ if html is not None:
466
+ html_body = html
467
+ else:
468
+ html_parts: List[str] = []
469
+ if text:
470
+ html_parts.append(f"<p>{_html.escape(text)}</p>")
471
+ if include_original:
472
+ quoted_html = quote_forward_html(original)
473
+ if quoted_html is not None:
474
+ html_parts.append(quoted_html)
475
+ html_body = "\n".join(html_parts) if html_parts else None
476
+
477
+ # Subject default
478
+ final_subject = subject or ensure_forward_subject(original.subject or "")
479
+
480
+ final_attachments = []
481
+ if attachments is not None:
482
+ final_attachments.extend(attachments)
483
+ if include_attachments and original.attachments:
484
+ final_attachments.extend(original.attachments)
485
+
486
+ msg = self.compose(
487
+ subject=final_subject,
488
+ to=to,
489
+ from_addr=from_addr,
490
+ cc=cc or (),
491
+ bcc=bcc or (),
492
+ text=text_body,
493
+ html=html_body,
494
+ attachments=final_attachments,
495
+ extra_headers=extra_headers,
496
+ )
497
+
498
+ return self.send(msg)
499
+
500
+ def imap_query(self, mailbox: str = "INBOX") -> EmailQuery:
501
+ return EmailQuery(self, mailbox=mailbox)
502
+
503
+ def fetch_overview(
504
+ self,
505
+ *,
506
+ mailbox: str = "INBOX",
507
+ n: int = 50,
508
+ before_uid: Optional[int] = None,
509
+ after_uid: Optional[int] = None,
510
+ refresh: bool = False,
511
+ ) -> tuple[PagedSearchResult, List[EmailOverview]]:
512
+ """
513
+ Fetch a page of EmailOverview objects with paging metadata.
514
+
515
+ - For the first (latest) page, call with refresh=True, before_uid=None.
516
+ - For next (older) pages, call with before_uid=prev_page.next_before_uid.
517
+ - For previous (newer) pages, call with after_uid=prev_page.prev_after_uid.
518
+ """
519
+ q = self.imap_query(mailbox).limit(n)
520
+ page, overviews = q.fetch_overview(
521
+ before_uid=before_uid,
522
+ after_uid=after_uid,
523
+ refresh=refresh,
524
+ )
525
+ return page, overviews
526
+
527
+ def fetch_latest(
528
+ self,
529
+ *,
530
+ mailbox: str = "INBOX",
531
+ n: int = 50,
532
+ unseen_only: bool = False,
533
+ include_attachment_meta: bool = False,
534
+ before_uid: Optional[int] = None,
535
+ after_uid: Optional[int] = None,
536
+ refresh: bool = False,
537
+ ) -> tuple[PagedSearchResult, List[EmailMessage]]:
538
+ """
539
+ Fetch a page of latest messages plus paging metadata.
540
+
541
+ - For the first (latest) page, call with refresh=True, before_uid=None.
542
+ - For next (older) pages, call with before_uid=prev_page.next_before_uid.
543
+ - For previous (newer) pages, call with after_uid=prev_page.prev_after_uid.
544
+ """
545
+ q = self.imap_query(mailbox).limit(n)
546
+ if unseen_only:
547
+ q.query.unseen()
548
+
549
+ page, messages = q.fetch(
550
+ before_uid=before_uid,
551
+ after_uid=after_uid,
552
+ refresh=refresh,
553
+ include_attachment_meta=include_attachment_meta,
554
+ )
555
+ return page, messages
556
+
557
+ def fetch_thread(
558
+ self,
559
+ root: EmailMessage,
560
+ *,
561
+ mailbox: str = "INBOX",
562
+ include_attachment_meta: bool = False,
563
+ ) -> List[EmailMessage]:
564
+ """
565
+ Fetch messages belonging to the same thread as `root`.
566
+ """
567
+ if not root.message_id:
568
+ return [root]
569
+
570
+ q = self.imap_query(mailbox).for_thread_root(root).limit(200)
571
+
572
+ _, msgs = q.fetch(include_attachment_meta=include_attachment_meta)
573
+
574
+ # Ensure root is present exactly once
575
+ mid = root.message_id
576
+ if all(m.message_id != mid for m in msgs):
577
+ msgs = [root] + msgs
578
+
579
+ return msgs
580
+
581
+ def add_flags(self, refs: Sequence[EmailRef], flags: Set[str]) -> None:
582
+ """Bulk add flags to refs."""
583
+ if not refs:
584
+ return
585
+ self.imap.add_flags(refs, flags=set(flags))
586
+
587
+ def remove_flags(self, refs: Sequence[EmailRef], flags: Set[str]) -> None:
588
+ """Bulk remove flags from refs."""
589
+ if not refs:
590
+ return
591
+ self.imap.remove_flags(refs, flags=set(flags))
592
+
593
+ def mark_seen(self, refs: Sequence[EmailRef]) -> None:
594
+ self.add_flags(refs, {SEEN})
595
+
596
+ def mark_all_seen(self, mailbox: str = "INBOX", *, chunk_size: int = 500) -> int:
597
+ total = 0
598
+
599
+ # Build a reusable EmailQuery for UNSEEN messages in this mailbox
600
+ q = self.imap_query(mailbox).limit(chunk_size)
601
+ q.query.unseen()
602
+
603
+ before_uid: Optional[int] = None
604
+ refresh = True # do a real SEARCH once to build the cache
605
+
606
+ while True:
607
+ page = q.search(before_uid=before_uid, refresh=refresh)
608
+ refresh = False # all further pages come from cache
609
+
610
+ refs = page.refs
611
+ if not refs:
612
+ break
613
+
614
+ self.add_flags(refs, {SEEN})
615
+ total += len(refs)
616
+
617
+ if not page.has_next or page.next_before_uid is None:
618
+ break
619
+
620
+ before_uid = page.next_before_uid
621
+
622
+ return total
623
+
624
+ def mark_unseen(self, refs: Sequence[EmailRef]) -> None:
625
+ self.remove_flags(refs, {SEEN})
626
+
627
+ def flag(self, refs: Sequence[EmailRef]) -> None:
628
+ self.add_flags(refs, {FLAGGED})
629
+
630
+ def unflag(self, refs: Sequence[EmailRef]) -> None:
631
+ self.remove_flags(refs, {FLAGGED})
632
+
633
+ def mark_answered(self, refs: Sequence[EmailRef]) -> None:
634
+ if refs:
635
+ self.add_flags(refs, {ANSWERED})
636
+
637
+ def clear_answered(self, refs: Sequence[EmailRef]) -> None:
638
+ if refs:
639
+ self.remove_flags(refs, {ANSWERED})
640
+
641
+ def delete(self, refs: Sequence[EmailRef]) -> None:
642
+ self.add_flags(refs, {DELETED})
643
+
644
+ def undelete(self, refs: Sequence[EmailRef]) -> None:
645
+ self.remove_flags(refs, {DELETED})
646
+
647
+ def expunge(self, mailbox: str = "INBOX") -> None:
648
+ """
649
+ Permanently remove messages flagged as \\Deleted.
650
+ """
651
+ self.imap.expunge(mailbox)
652
+
653
+ def list_mailboxes(self) -> List[str]:
654
+ """
655
+ Return a list of mailbox names.
656
+ """
657
+ return self.imap.list_mailboxes()
658
+
659
+ def mailbox_status(self, mailbox: str = "INBOX") -> Dict[str, int]:
660
+ """
661
+ Return counters, e.g. {"messages": X, "unseen": Y}.
662
+ """
663
+ return self.imap.mailbox_status(mailbox)
664
+
665
+ def move(
666
+ self,
667
+ refs: Sequence[EmailRef],
668
+ *,
669
+ src_mailbox: str,
670
+ dst_mailbox: str,
671
+ ) -> None:
672
+ """
673
+ Move messages between mailboxes.
674
+ """
675
+ if not refs:
676
+ return
677
+ self.imap.move(refs, src_mailbox=src_mailbox, dst_mailbox=dst_mailbox)
678
+
679
+ def copy(
680
+ self,
681
+ refs: Sequence[EmailRef],
682
+ *,
683
+ src_mailbox: str,
684
+ dst_mailbox: str,
685
+ ) -> None:
686
+ """
687
+ Copy messages between mailboxes.
688
+ """
689
+ if not refs:
690
+ return
691
+ self.imap.copy(refs, src_mailbox=src_mailbox, dst_mailbox=dst_mailbox)
692
+
693
+ def create_mailbox(self, name: str) -> None:
694
+ """
695
+ Create a new mailbox/folder.
696
+ """
697
+ self.imap.create_mailbox(name)
698
+
699
+ def delete_mailbox(self, name: str) -> None:
700
+ """
701
+ Delete a mailbox/folder.
702
+ """
703
+ self.imap.delete_mailbox(name)
704
+
705
+ def list_unsubscribe_candidates(
706
+ self,
707
+ *,
708
+ mailbox: str = "INBOX",
709
+ limit: int = 200,
710
+ since: Optional[str] = None,
711
+ unseen_only: bool = False,
712
+ ) -> List[UnsubscribeCandidate]:
713
+ """
714
+ Returns emails that expose List-Unsubscribe.
715
+ """
716
+ detector = SubscriptionDetector(self.imap)
717
+ return detector.find(
718
+ mailbox=mailbox,
719
+ limit=limit,
720
+ since=since,
721
+ unseen_only=unseen_only,
722
+ )
723
+
724
+ def unsubscribe_selected(
725
+ self,
726
+ candidates: Sequence[UnsubscribeCandidate],
727
+ *,
728
+ prefer: str = "mailto",
729
+ from_addr: Optional[str] = None,
730
+ ) -> Dict[str, List[UnsubscribeActionResult]]:
731
+ """
732
+ Delegates unsubscribe execution to SubscriptionService.
733
+ """
734
+ service = SubscriptionService(self.smtp)
735
+ return service.unsubscribe(
736
+ list(candidates),
737
+ prefer=prefer,
738
+ from_addr=from_addr,
739
+ )
740
+
741
+ def health_check(self) -> Dict[str, bool]:
742
+ """
743
+ Run minimal IMAP + SMTP checks.
744
+ """
745
+ imap_ok = False
746
+ smtp_ok = False
747
+
748
+ try:
749
+ self.imap.ping() # or list_mailboxes(), or NOOP
750
+ imap_ok = True
751
+ except Exception:
752
+ pass
753
+
754
+ try:
755
+ self.smtp.ping() # or EHLO/NOOP
756
+ smtp_ok = True
757
+ except Exception:
758
+ pass
759
+
760
+ return {"imap": imap_ok, "smtp": smtp_ok}
761
+
762
+ def close(self) -> None:
763
+ # Best-effort close both
764
+ try:
765
+ self.imap.close()
766
+ except Exception:
767
+ pass
768
+ try:
769
+ self.smtp.close()
770
+ except Exception:
771
+ pass
772
+
773
+ def __enter__(self) -> EmailManager:
774
+ return self
775
+
776
+ def __exit__(self, exc_type, exc, tb) -> None:
777
+ self.close()