MailToolsBox 1.0.1__tar.gz → 2.0.0__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.
@@ -1,17 +1,17 @@
1
- MIT License
2
- Copyright (c) 2018 Rambod Ghashghai
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
- The above copyright notice and this permission notice shall be included in all
10
- copies or substantial portions of the Software.
11
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17
- SOFTWARE.
1
+ MIT License
2
+ Copyright (c) 2018 Rambod Ghashghai
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+ The above copyright notice and this permission notice shall be included in all
10
+ copies or substantial portions of the Software.
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ from MailToolsBox.mailSender import EmailSender, SendAgent
2
+ from MailToolsBox.imapClient import ImapAgent, ImapClient
3
+
4
+ __version__ = "2.0.0"
5
+
6
+ __all__ = ["EmailSender", "SendAgent", "ImapClient", "ImapAgent", "__version__"]
@@ -0,0 +1,633 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import datetime as dt
5
+ import email
6
+ import imaplib
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import ssl
12
+ from dataclasses import dataclass, field
13
+ from email.header import decode_header, make_header
14
+ from email.message import Message
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
18
+
19
+ logger = logging.getLogger(__name__)
20
+ logger.addHandler(logging.NullHandler())
21
+
22
+
23
+ # ----------------------------- Security -----------------------------
24
+
25
+ class SecurityMode(str, Enum):
26
+ AUTO = "auto" # 993 -> SSL on connect, else try STARTTLS if server advertises it
27
+ STARTTLS = "starttls"
28
+ SSL = "ssl" # implicit TLS on connect
29
+ NONE = "none" # plaintext, only for trusted LANs
30
+
31
+
32
+ # ----------------------------- Models -------------------------------
33
+
34
+ @dataclass
35
+ class MailAddress:
36
+ name: Optional[str]
37
+ email: Optional[str]
38
+
39
+
40
+ @dataclass
41
+ class MailPart:
42
+ content_type: str
43
+ charset: Optional[str]
44
+ content: bytes
45
+ filename: Optional[str] = None
46
+ is_attachment: bool = False
47
+
48
+
49
+ @dataclass
50
+ class MailItem:
51
+ uid: str
52
+ subject: str
53
+ from_: List[MailAddress] = field(default_factory=list)
54
+ to: List[MailAddress] = field(default_factory=list)
55
+ cc: List[MailAddress] = field(default_factory=list)
56
+ date: Optional[dt.datetime] = None
57
+ flags: List[str] = field(default_factory=list)
58
+ text: Optional[str] = None
59
+ html: Optional[str] = None
60
+ attachments: List[MailPart] = field(default_factory=list)
61
+ raw: bytes = b""
62
+
63
+
64
+ # --------------------------- Utilities ------------------------------
65
+
66
+ def _decode_header_value(value: Optional[str]) -> str:
67
+ if not value:
68
+ return ""
69
+ try:
70
+ return str(make_header(decode_header(value)))
71
+ except Exception:
72
+ return value
73
+
74
+
75
+ def _decode_filename(value: Optional[str]) -> str:
76
+ if not value:
77
+ return ""
78
+ try:
79
+ return str(make_header(decode_header(value)))
80
+ except Exception:
81
+ return value
82
+
83
+ def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
84
+ if not value:
85
+ return []
86
+ addrs = email.utils.getaddresses([value])
87
+ out: List[MailAddress] = []
88
+ for name, addr in addrs:
89
+ out.append(MailAddress(_decode_header_value(name) or None, addr or None))
90
+ return out
91
+
92
+ def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
93
+ if not date_hdr:
94
+ return None
95
+ try:
96
+ dt_obj = email.utils.parsedate_to_datetime(date_hdr)
97
+ if dt_obj is None:
98
+ return None
99
+ # Assume UTC when timezone missing to keep comparisons consistent.
100
+ if dt_obj.tzinfo is None:
101
+ dt_obj = dt_obj.replace(tzinfo=dt.timezone.utc)
102
+ return dt_obj
103
+ except Exception:
104
+ return None
105
+
106
+
107
+ # ----------------------------- Client --------------------------------
108
+
109
+ class ImapClient:
110
+ """
111
+ Improved IMAP client with:
112
+ - Security modes: auto, starttls, ssl, none
113
+ - Optional XOAUTH2 token authentication
114
+ - List, search, move, delete, flag, and fetch utilities
115
+ - Correct header decoding and body extraction
116
+ - Attachment saving
117
+ - Context manager support
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ email_account: str,
123
+ password: Optional[str],
124
+ server_address: str,
125
+ *,
126
+ port: int = 993,
127
+ security_mode: SecurityMode = SecurityMode.AUTO,
128
+ oauth2_access_token: Optional[str] = None,
129
+ ssl_context: Optional[ssl.SSLContext] = None,
130
+ allow_invalid_certs: bool = False,
131
+ timeout: int = 30,
132
+ ) -> None:
133
+ self.email_account = email_account
134
+ self.password = password
135
+ self.server_address = server_address
136
+ self.port = int(port)
137
+ self.security_mode = SecurityMode(security_mode)
138
+ self.oauth2_access_token = oauth2_access_token
139
+ self.timeout = timeout
140
+
141
+ ctx = ssl_context or ssl.create_default_context()
142
+ if allow_invalid_certs:
143
+ ctx.check_hostname = False
144
+ ctx.verify_mode = ssl.CERT_NONE
145
+ self.ssl_context = ctx
146
+
147
+ self.conn: Optional[imaplib.IMAP4] = None
148
+ self.selected_mailbox: Optional[str] = None
149
+
150
+ # --------- factories
151
+
152
+ @classmethod
153
+ def from_env(cls) -> "ImapClient":
154
+ """
155
+ Required: IMAP_EMAIL, IMAP_SERVER
156
+ Optional: IMAP_PASSWORD, IMAP_PORT, IMAP_SECURITY, IMAP_OAUTH2_TOKEN, IMAP_ALLOW_INVALID_CERTS
157
+ """
158
+ email_account = os.environ["IMAP_EMAIL"]
159
+ server = os.environ["IMAP_SERVER"]
160
+ password = os.getenv("IMAP_PASSWORD") or None
161
+ port = int(os.getenv("IMAP_PORT", "993"))
162
+ security = os.getenv("IMAP_SECURITY", "auto")
163
+ token = os.getenv("IMAP_OAUTH2_TOKEN") or None
164
+ allow_invalid = os.getenv("IMAP_ALLOW_INVALID_CERTS", "0") in {"1", "true", "True"}
165
+
166
+ return cls(
167
+ email_account=email_account,
168
+ password=password,
169
+ server_address=server,
170
+ port=port,
171
+ security_mode=security,
172
+ oauth2_access_token=token,
173
+ allow_invalid_certs=allow_invalid,
174
+ )
175
+
176
+ # --------- context manager
177
+
178
+ def __enter__(self) -> "ImapClient":
179
+ self.login()
180
+ return self
181
+
182
+ def __exit__(self, exc_type, exc, tb) -> None:
183
+ self.logout()
184
+
185
+ # --------- connection
186
+
187
+ def _open(self) -> imaplib.IMAP4:
188
+ mode = self.security_mode
189
+ if mode == SecurityMode.AUTO and self.port == 993:
190
+ mode = SecurityMode.SSL
191
+
192
+ if mode == SecurityMode.SSL:
193
+ conn = imaplib.IMAP4_SSL(self.server_address, self.port, ssl_context=self.ssl_context)
194
+ conn.timeout = self.timeout
195
+ return conn
196
+
197
+ conn = imaplib.IMAP4(self.server_address, self.port)
198
+ conn.timeout = self.timeout
199
+ if mode == SecurityMode.STARTTLS or (mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities):
200
+ conn.starttls(self.ssl_context)
201
+ # capabilities may change after STARTTLS
202
+ conn.capabilities = conn.capability()[1][0].split() # refresh
203
+ return conn
204
+
205
+ def _auth(self, conn: imaplib.IMAP4) -> None:
206
+ if self.oauth2_access_token:
207
+ # XOAUTH2: base64("user=<email>\x01auth=Bearer <token>\x01\x01")
208
+ raw = f"user={self.email_account}\x01auth=Bearer {self.oauth2_access_token}\x01\x01".encode("utf-8")
209
+ xoauth = base64.b64encode(raw).decode("ascii")
210
+ typ, resp = conn.authenticate("XOAUTH2", lambda _: xoauth)
211
+ if typ != "OK":
212
+ raise RuntimeError(f"XOAUTH2 failed: {resp}")
213
+ return
214
+ if self.password is None:
215
+ raise RuntimeError("No password or OAuth2 token provided for IMAP authentication")
216
+ typ, resp = conn.login(self.email_account, self.password)
217
+ if typ != "OK":
218
+ raise RuntimeError(f"IMAP login failed: {resp}")
219
+
220
+ def login(self) -> None:
221
+ if self.conn:
222
+ return
223
+ self.conn = self._open()
224
+ self._auth(self.conn)
225
+
226
+ def logout(self) -> None:
227
+ if not self.conn:
228
+ return
229
+ try:
230
+ try:
231
+ self.conn.close()
232
+ except Exception:
233
+ pass
234
+ self.conn.logout()
235
+ finally:
236
+ self.conn = None
237
+ self.selected_mailbox = None
238
+
239
+ # --------- mailbox ops
240
+
241
+ def list_mailboxes(self) -> List[str]:
242
+ self._ensure()
243
+ typ, data = self.conn.list()
244
+ if typ != "OK":
245
+ return []
246
+ names: List[str] = []
247
+ for line in data:
248
+ # format: b'(\\HasNoChildren) "/" "INBOX/Sub"'
249
+ if not line:
250
+ continue
251
+ decoded = line.decode("utf-8", errors="ignore")
252
+ match = re.search(r'"((?:\\.|[^"])*)"$', decoded)
253
+ if match:
254
+ name = match.group(1).replace(r"\"", '"')
255
+ else:
256
+ name = decoded.split(" ", 1)[-1].strip('"')
257
+ names.append(name)
258
+ return names
259
+
260
+ def select(self, mailbox: str = "INBOX", readonly: bool = True) -> None:
261
+ self._ensure()
262
+ typ, _ = self.conn.select(mailbox, readonly=readonly)
263
+ if typ != "OK":
264
+ raise RuntimeError(f"Cannot select mailbox {mailbox}")
265
+ self.selected_mailbox = mailbox
266
+
267
+ # --------- search and fetch
268
+
269
+ def search(self, *criteria: str) -> List[str]:
270
+ """
271
+ Search with IMAP criteria. Example:
272
+ client.search('UNSEEN', 'SINCE', '01-Jan-2025', 'FROM', '"alerts@service.com"')
273
+ Returns a list of UIDs as strings.
274
+ """
275
+ self._ensure_selected()
276
+ if not criteria:
277
+ criteria = ("ALL",)
278
+ typ, data = self.conn.uid("search", None, *criteria)
279
+ if typ != "OK" or not data or not data[0]:
280
+ return []
281
+ uids = data[0].decode().split()
282
+ return uids
283
+
284
+ def fetch_raw(self, uid: str) -> Tuple[bytes, List[str]]:
285
+ self._ensure_selected()
286
+ typ, data = self.conn.uid("fetch", uid, "(RFC822 FLAGS)")
287
+ if typ != "OK" or not data or not isinstance(data[0], tuple):
288
+ raise RuntimeError(f"Failed to fetch UID {uid}")
289
+ raw: bytes = data[0][1]
290
+ # FLAGS come in a separate item depending on server, normalize
291
+ flags: List[str] = []
292
+ try:
293
+ for item in data:
294
+ if isinstance(item, tuple) and b"FLAGS" in item[0]:
295
+ flags_blob = item[0].decode(errors="ignore")
296
+ start = flags_blob.find("(")
297
+ end = flags_blob.find(")")
298
+ if start >= 0 and end > start:
299
+ flags = flags_blob[start + 1:end].split()
300
+ break
301
+ except Exception:
302
+ flags = []
303
+ return raw, flags
304
+
305
+ def _parse_message(self, uid: str, raw: bytes, flags: List[str]) -> MailItem:
306
+ msg: Message = email.message_from_bytes(raw)
307
+ subject = _decode_header_value(msg.get("Subject"))
308
+ from_list = _parse_addresses(msg.get("From"))
309
+ to_list = _parse_addresses(msg.get("To"))
310
+ cc_list = _parse_addresses(msg.get("Cc"))
311
+ date = _to_local_datetime(msg.get("Date"))
312
+
313
+ text_body: Optional[str] = None
314
+ html_body: Optional[str] = None
315
+ attachments: List[MailPart] = []
316
+ used_names: Set[str] = set()
317
+
318
+ if msg.is_multipart():
319
+ for part in msg.walk():
320
+ ctype = part.get_content_type()
321
+ dispo = str(part.get("Content-Disposition") or "")
322
+ filename_raw = part.get_filename()
323
+ filename = _decode_filename(filename_raw)
324
+ if filename:
325
+ filename = Path(filename).name
326
+ if filename in used_names:
327
+ stem = Path(filename).stem
328
+ suffix = Path(filename).suffix
329
+ counter = 1
330
+ candidate = f"{stem}_{counter}{suffix}"
331
+ while candidate in used_names:
332
+ counter += 1
333
+ candidate = f"{stem}_{counter}{suffix}"
334
+ filename = candidate
335
+ used_names.add(filename)
336
+ is_attach = "attachment" in dispo.lower() or bool(filename)
337
+ charset = part.get_content_charset() or "utf-8"
338
+
339
+ if is_attach:
340
+ payload = part.get_payload(decode=True) or b""
341
+ attachments.append(MailPart(ctype, charset, payload, filename=filename, is_attachment=True))
342
+ continue
343
+
344
+ if ctype == "text/plain":
345
+ payload = part.get_payload(decode=True) or b""
346
+ try:
347
+ text_body = payload.decode(charset, errors="replace")
348
+ except Exception:
349
+ text_body = payload.decode("utf-8", errors="replace")
350
+ elif ctype == "text/html":
351
+ payload = part.get_payload(decode=True) or b""
352
+ try:
353
+ html_body = payload.decode(charset, errors="replace")
354
+ except Exception:
355
+ html_body = payload.decode("utf-8", errors="replace")
356
+ else:
357
+ ctype = msg.get_content_type()
358
+ charset = msg.get_content_charset() or "utf-8"
359
+ payload = msg.get_payload(decode=True) or b""
360
+ try:
361
+ body_text = payload.decode(charset, errors="replace")
362
+ except Exception:
363
+ body_text = payload.decode("utf-8", errors="replace")
364
+ if ctype == "text/html":
365
+ html_body = body_text
366
+ else:
367
+ text_body = body_text
368
+
369
+ return MailItem(
370
+ uid=uid,
371
+ subject=subject,
372
+ from_=from_list,
373
+ to=to_list,
374
+ cc=cc_list,
375
+ date=date,
376
+ flags=flags,
377
+ text=text_body,
378
+ html=html_body,
379
+ attachments=attachments,
380
+ raw=raw,
381
+ )
382
+
383
+ def fetch(self, uid: str) -> MailItem:
384
+ raw, flags = self.fetch_raw(uid)
385
+ return self._parse_message(uid, raw, flags)
386
+
387
+ def fetch_many(self, uids: Iterable[str]) -> List[MailItem]:
388
+ out: List[MailItem] = []
389
+ for uid in uids:
390
+ try:
391
+ out.append(self.fetch(uid))
392
+ except Exception as exc:
393
+ logger.warning("Failed to fetch UID %s: %s", uid, exc)
394
+ continue
395
+ return out
396
+
397
+ # --------- mutate flags and move
398
+
399
+ def add_flags(self, uid: str, *flags: str) -> None:
400
+ self._ensure_selected()
401
+ self.conn.uid("store", uid, "+FLAGS", f"({' '.join(flags)})")
402
+
403
+ def remove_flags(self, uid: str, *flags: str) -> None:
404
+ self._ensure_selected()
405
+ self.conn.uid("store", uid, "-FLAGS", f"({' '.join(flags)})")
406
+
407
+ def mark_seen(self, uid: str) -> None:
408
+ self.add_flags(uid, "\\Seen")
409
+
410
+ def delete(self, uid: str) -> None:
411
+ self.add_flags(uid, "\\Deleted")
412
+
413
+ def expunge(self) -> None:
414
+ self._ensure_selected()
415
+ self.conn.expunge()
416
+
417
+ def move(self, uid_list: Iterable[str], destination: str) -> None:
418
+ """
419
+ Move messages by copying then marking deleted. Works on servers without MOVE.
420
+ """
421
+ self._ensure_selected()
422
+ ids = ",".join(uid_list)
423
+ typ, _ = self.conn.uid("COPY", ids, destination)
424
+ if typ == "OK":
425
+ self.conn.uid("STORE", ids, "+FLAGS", "(\\Deleted)")
426
+
427
+ # --------- exports
428
+
429
+ def save_eml(self, item: MailItem, path: str | Path) -> Path:
430
+ p = Path(path)
431
+ p.write_bytes(item.raw)
432
+ return p
433
+
434
+ def save_attachments(self, item: MailItem, directory: str | Path) -> List[Path]:
435
+ out: List[Path] = []
436
+ d = Path(directory)
437
+ d.mkdir(parents=True, exist_ok=True)
438
+ for part in item.attachments:
439
+ if not part.filename:
440
+ continue
441
+ target = d / part.filename
442
+ target.write_bytes(part.content)
443
+ out.append(target)
444
+ return out
445
+
446
+ # --------- legacy style exports (modernized)
447
+
448
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
449
+ """
450
+ Dump all messages in a mailbox to a single UTF-8 text file.
451
+ """
452
+ self.login()
453
+ self.select(mailbox)
454
+ uids = self.search("ALL")
455
+ lines: List[str] = []
456
+ for uid in uids:
457
+ msg = self.fetch(uid)
458
+ date_str = msg.date.strftime("%a, %d %b %Y %H:%M:%S") if msg.date else ""
459
+ from_str = ", ".join(f"{a.name or ''} <{a.email or ''}>".strip() for a in msg.from_)
460
+ to_str = ", ".join(f"{a.name or ''} <{a.email or ''}>".strip() for a in msg.to)
461
+ body = msg.text or msg.html or ""
462
+ lines.append(
463
+ f"From: {from_str}\nTo: {to_str}\nDate: {date_str}\nSubject: {msg.subject}\n\n{body}\n\n{'-'*60}\n"
464
+ )
465
+ out = Path(path) / "email.txt"
466
+ out.write_text("".join(lines), encoding="utf-8")
467
+ self.logout()
468
+ return out
469
+
470
+ def download_mail_json(
471
+ self,
472
+ lookup: str = "ALL",
473
+ save: bool = False,
474
+ path: str = "",
475
+ file_name: str = "mail.json",
476
+ mailbox: str = "INBOX",
477
+ ) -> str:
478
+ """
479
+ Return JSON of selected messages. Optionally save to disk.
480
+ """
481
+ self.login()
482
+ self.select(mailbox)
483
+ uids = self.search(lookup)
484
+ items = self.fetch_many(uids)
485
+ payload: List[Dict[str, Any]] = []
486
+ for m in items:
487
+ payload.append(
488
+ {
489
+ "uid": m.uid,
490
+ "subject": m.subject,
491
+ "from": [{"name": a.name, "email": a.email} for a in m.from_],
492
+ "to": [{"name": a.name, "email": a.email} for a in m.to],
493
+ "cc": [{"name": a.name, "email": a.email} for a in m.cc],
494
+ "date": m.date.isoformat() if m.date else None,
495
+ "flags": m.flags,
496
+ "text": m.text,
497
+ "html": m.html,
498
+ "attachments": [a.filename for a in m.attachments if a.filename],
499
+ }
500
+ )
501
+ s = json.dumps(payload, ensure_ascii=False, indent=2)
502
+ if save:
503
+ out = Path(path) / file_name
504
+ out.write_text(s, encoding="utf-8")
505
+ self.logout()
506
+ return s
507
+
508
+ def download_mail_eml(self, directory: str = "", lookup: str = "ALL", mailbox: str = "INBOX") -> List[Path]:
509
+ """
510
+ Save each selected message as an .eml file.
511
+ """
512
+ self.login()
513
+ self.select(mailbox)
514
+ uids = self.search(lookup)
515
+ out_paths: List[Path] = []
516
+ target_dir = Path(directory or ".")
517
+ target_dir.mkdir(parents=True, exist_ok=True)
518
+ for i, uid in enumerate(uids):
519
+ item = self.fetch(uid)
520
+ file_name = f"email_{i}_{uid}.eml"
521
+ out = target_dir / file_name
522
+ out.write_bytes(item.raw)
523
+ out_paths.append(out)
524
+ self.logout()
525
+ return out_paths
526
+
527
+ # --------- internal guards
528
+
529
+ def _ensure(self) -> None:
530
+ if not self.conn:
531
+ self.login()
532
+
533
+ def _ensure_selected(self) -> None:
534
+ self._ensure()
535
+ if not self.selected_mailbox:
536
+ self.select("INBOX")
537
+
538
+
539
+ # ----------------------------- Legacy shim -----------------------------
540
+
541
+
542
+ class ImapAgent(ImapClient):
543
+ """
544
+ Backward compatible adapter that mirrors the old ImapAgent API
545
+ on top of the modern ImapClient.
546
+ """
547
+
548
+ def __init__(
549
+ self,
550
+ email_account: str,
551
+ password: Optional[str],
552
+ server_address: str,
553
+ *,
554
+ port: int = 993,
555
+ security_mode: SecurityMode = SecurityMode.SSL,
556
+ oauth2_access_token: Optional[str] = None,
557
+ ssl_context: Optional[ssl.SSLContext] = None,
558
+ allow_invalid_certs: bool = False,
559
+ timeout: int = 30,
560
+ ) -> None:
561
+ super().__init__(
562
+ email_account=email_account,
563
+ password=password,
564
+ server_address=server_address,
565
+ port=port,
566
+ security_mode=security_mode,
567
+ oauth2_access_token=oauth2_access_token,
568
+ ssl_context=ssl_context,
569
+ allow_invalid_certs=allow_invalid_certs,
570
+ timeout=timeout,
571
+ )
572
+ self.mail: Optional[imaplib.IMAP4] = None
573
+
574
+ def login_account(self) -> imaplib.IMAP4:
575
+ self.login()
576
+ self.mail = self.conn
577
+ return self.mail
578
+
579
+ def logout_account(self) -> None:
580
+ self.logout()
581
+ self.mail = None
582
+
583
+ def __enter__(self) -> "ImapAgent":
584
+ self.login_account()
585
+ return self
586
+
587
+ def __exit__(self, exc_type, exc, tb) -> None:
588
+ self.logout_account()
589
+
590
+ def _sync_mail_alias(self) -> None:
591
+ if self.mail and self.conn is None:
592
+ self.conn = self.mail
593
+
594
+ def logout(self) -> None:
595
+ super().logout()
596
+ self.mail = None
597
+
598
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
599
+ self._sync_mail_alias()
600
+ return super().download_mail_text(path=path, mailbox=mailbox)
601
+
602
+ def download_mail_json(
603
+ self,
604
+ lookup: str = "ALL",
605
+ save: bool = False,
606
+ path: str = "",
607
+ file_name: str = "mail.json",
608
+ mailbox: str = "INBOX",
609
+ ) -> str:
610
+ self._sync_mail_alias()
611
+ return super().download_mail_json(lookup=lookup, save=save, path=path, file_name=file_name, mailbox=mailbox)
612
+
613
+ def download_mail_msg(self, path: str = "", lookup: str = "ALL", mailbox: str = "INBOX") -> List[Path]:
614
+ """
615
+ Save each selected message as a .msg file (wrapper over .eml bytes).
616
+ """
617
+ self._sync_mail_alias()
618
+ self.login()
619
+ try:
620
+ self.select(mailbox)
621
+ uids = self.search(lookup)
622
+ out_paths: List[Path] = []
623
+ target_dir = Path(path or ".")
624
+ target_dir.mkdir(parents=True, exist_ok=True)
625
+ for i, uid in enumerate(uids):
626
+ item = self.fetch(uid)
627
+ file_name = f"email_{i}.msg"
628
+ out = target_dir / file_name
629
+ out.write_bytes(item.raw)
630
+ out_paths.append(out)
631
+ return out_paths
632
+ finally:
633
+ self.logout()