MailToolsBox 1.0.0__tar.gz → 1.1.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
3
+
4
+ __version__ = "1.0.1"
5
+
6
+ __all__ = ["EmailSender", "SendAgent", "ImapAgent", "__version__"]
@@ -0,0 +1,501 @@
1
+ from __future__ import annotations
2
+
3
+ import imaplib
4
+ import email
5
+ import ssl
6
+ import os
7
+ import json
8
+ import datetime as dt
9
+ import base64
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Optional, Iterable, Tuple, Dict, Any
12
+ from pathlib import Path
13
+ from enum import Enum
14
+ from email.header import decode_header, make_header
15
+ from email.message import Message
16
+
17
+
18
+ # ----------------------------- Security -----------------------------
19
+
20
+ class SecurityMode(str, Enum):
21
+ AUTO = "auto" # 993 -> SSL on connect, else try STARTTLS if server advertises it
22
+ STARTTLS = "starttls"
23
+ SSL = "ssl" # implicit TLS on connect
24
+ NONE = "none" # plaintext, only for trusted LANs
25
+
26
+
27
+ # ----------------------------- Models -------------------------------
28
+
29
+ @dataclass
30
+ class MailAddress:
31
+ name: Optional[str]
32
+ email: Optional[str]
33
+
34
+
35
+ @dataclass
36
+ class MailPart:
37
+ content_type: str
38
+ charset: Optional[str]
39
+ content: bytes
40
+ filename: Optional[str] = None
41
+ is_attachment: bool = False
42
+
43
+
44
+ @dataclass
45
+ class MailItem:
46
+ uid: str
47
+ subject: str
48
+ from_: List[MailAddress] = field(default_factory=list)
49
+ to: List[MailAddress] = field(default_factory=list)
50
+ cc: List[MailAddress] = field(default_factory=list)
51
+ date: Optional[dt.datetime] = None
52
+ flags: List[str] = field(default_factory=list)
53
+ text: Optional[str] = None
54
+ html: Optional[str] = None
55
+ attachments: List[MailPart] = field(default_factory=list)
56
+ raw: bytes = b""
57
+
58
+
59
+ # --------------------------- Utilities ------------------------------
60
+
61
+ def _decode_header_value(value: Optional[str]) -> str:
62
+ if not value:
63
+ return ""
64
+ try:
65
+ return str(make_header(decode_header(value)))
66
+ except Exception:
67
+ return value
68
+
69
+ def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
70
+ if not value:
71
+ return []
72
+ addrs = email.utils.getaddresses([value])
73
+ out: List[MailAddress] = []
74
+ for name, addr in addrs:
75
+ out.append(MailAddress(_decode_header_value(name) or None, addr or None))
76
+ return out
77
+
78
+ def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
79
+ if not date_hdr:
80
+ return None
81
+ try:
82
+ t = email.utils.parsedate_tz(date_hdr)
83
+ if not t:
84
+ return None
85
+ timestamp = email.utils.mktime_tz(t)
86
+ return dt.datetime.fromtimestamp(timestamp)
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ # ----------------------------- Client --------------------------------
92
+
93
+ class ImapClient:
94
+ """
95
+ Improved IMAP client with:
96
+ - Security modes: auto, starttls, ssl, none
97
+ - Optional XOAUTH2 token authentication
98
+ - List, search, move, delete, flag, and fetch utilities
99
+ - Correct header decoding and body extraction
100
+ - Attachment saving
101
+ - Context manager support
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ email_account: str,
107
+ password: Optional[str],
108
+ server_address: str,
109
+ *,
110
+ port: int = 993,
111
+ security_mode: SecurityMode = SecurityMode.AUTO,
112
+ oauth2_access_token: Optional[str] = None,
113
+ ssl_context: Optional[ssl.SSLContext] = None,
114
+ allow_invalid_certs: bool = False,
115
+ timeout: int = 30,
116
+ ) -> None:
117
+ self.email_account = email_account
118
+ self.password = password
119
+ self.server_address = server_address
120
+ self.port = int(port)
121
+ self.security_mode = SecurityMode(security_mode)
122
+ self.oauth2_access_token = oauth2_access_token
123
+ self.timeout = timeout
124
+
125
+ ctx = ssl_context or ssl.create_default_context()
126
+ if allow_invalid_certs:
127
+ ctx.check_hostname = False
128
+ ctx.verify_mode = ssl.CERT_NONE
129
+ self.ssl_context = ctx
130
+
131
+ self.conn: Optional[imaplib.IMAP4] = None
132
+ self.selected_mailbox: Optional[str] = None
133
+
134
+ # --------- factories
135
+
136
+ @classmethod
137
+ def from_env(cls) -> "ImapClient":
138
+ """
139
+ Required: IMAP_EMAIL, IMAP_SERVER
140
+ Optional: IMAP_PASSWORD, IMAP_PORT, IMAP_SECURITY, IMAP_OAUTH2_TOKEN, IMAP_ALLOW_INVALID_CERTS
141
+ """
142
+ email_account = os.environ["IMAP_EMAIL"]
143
+ server = os.environ["IMAP_SERVER"]
144
+ password = os.getenv("IMAP_PASSWORD") or None
145
+ port = int(os.getenv("IMAP_PORT", "993"))
146
+ security = os.getenv("IMAP_SECURITY", "auto")
147
+ token = os.getenv("IMAP_OAUTH2_TOKEN") or None
148
+ allow_invalid = os.getenv("IMAP_ALLOW_INVALID_CERTS", "0") in {"1", "true", "True"}
149
+
150
+ return cls(
151
+ email_account=email_account,
152
+ password=password,
153
+ server_address=server,
154
+ port=port,
155
+ security_mode=security,
156
+ oauth2_access_token=token,
157
+ allow_invalid_certs=allow_invalid,
158
+ )
159
+
160
+ # --------- context manager
161
+
162
+ def __enter__(self) -> "ImapClient":
163
+ self.login()
164
+ return self
165
+
166
+ def __exit__(self, exc_type, exc, tb) -> None:
167
+ self.logout()
168
+
169
+ # --------- connection
170
+
171
+ def _open(self) -> imaplib.IMAP4:
172
+ mode = self.security_mode
173
+ if mode == SecurityMode.AUTO and self.port == 993:
174
+ mode = SecurityMode.SSL
175
+
176
+ if mode == SecurityMode.SSL:
177
+ conn = imaplib.IMAP4_SSL(self.server_address, self.port, ssl_context=self.ssl_context)
178
+ conn.timeout = self.timeout
179
+ return conn
180
+
181
+ conn = imaplib.IMAP4(self.server_address, self.port)
182
+ conn.timeout = self.timeout
183
+ if mode == SecurityMode.STARTTLS or (mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities):
184
+ conn.starttls(self.ssl_context)
185
+ # capabilities may change after STARTTLS
186
+ conn.capabilities = conn.capability()[1][0].split() # refresh
187
+ return conn
188
+
189
+ def _auth(self, conn: imaplib.IMAP4) -> None:
190
+ if self.oauth2_access_token:
191
+ # XOAUTH2: base64("user=<email>\x01auth=Bearer <token>\x01\x01")
192
+ raw = f"user={self.email_account}\x01auth=Bearer {self.oauth2_access_token}\x01\x01".encode("utf-8")
193
+ xoauth = base64.b64encode(raw).decode("ascii")
194
+ typ, resp = conn.authenticate("XOAUTH2", lambda _: xoauth)
195
+ if typ != "OK":
196
+ raise RuntimeError(f"XOAUTH2 failed: {resp}")
197
+ return
198
+ if self.password is None:
199
+ raise RuntimeError("No password or OAuth2 token provided for IMAP authentication")
200
+ typ, resp = conn.login(self.email_account, self.password)
201
+ if typ != "OK":
202
+ raise RuntimeError(f"IMAP login failed: {resp}")
203
+
204
+ def login(self) -> None:
205
+ if self.conn:
206
+ return
207
+ self.conn = self._open()
208
+ self._auth(self.conn)
209
+
210
+ def logout(self) -> None:
211
+ if not self.conn:
212
+ return
213
+ try:
214
+ try:
215
+ self.conn.close()
216
+ except Exception:
217
+ pass
218
+ self.conn.logout()
219
+ finally:
220
+ self.conn = None
221
+ self.selected_mailbox = None
222
+
223
+ # --------- mailbox ops
224
+
225
+ def list_mailboxes(self) -> List[str]:
226
+ self._ensure()
227
+ typ, data = self.conn.list()
228
+ if typ != "OK":
229
+ return []
230
+ names: List[str] = []
231
+ for line in data:
232
+ # format: b'(\\HasNoChildren) "/" "INBOX/Sub"'
233
+ if not line:
234
+ continue
235
+ parts = line.decode("utf-8", errors="ignore").split(" ")
236
+ name = parts[-1].strip('"')
237
+ names.append(name)
238
+ return names
239
+
240
+ def select(self, mailbox: str = "INBOX", readonly: bool = True) -> None:
241
+ self._ensure()
242
+ typ, _ = self.conn.select(mailbox, readonly=readonly)
243
+ if typ != "OK":
244
+ raise RuntimeError(f"Cannot select mailbox {mailbox}")
245
+ self.selected_mailbox = mailbox
246
+
247
+ # --------- search and fetch
248
+
249
+ def search(self, *criteria: str) -> List[str]:
250
+ """
251
+ Search with IMAP criteria. Example:
252
+ client.search('UNSEEN', 'SINCE', '01-Jan-2025', 'FROM', '"alerts@service.com"')
253
+ Returns a list of UIDs as strings.
254
+ """
255
+ self._ensure_selected()
256
+ if not criteria:
257
+ criteria = ("ALL",)
258
+ typ, data = self.conn.uid("search", None, *criteria)
259
+ if typ != "OK" or not data or not data[0]:
260
+ return []
261
+ uids = data[0].decode().split()
262
+ return uids
263
+
264
+ def fetch_raw(self, uid: str) -> Tuple[bytes, List[str]]:
265
+ self._ensure_selected()
266
+ typ, data = self.conn.uid("fetch", uid, "(RFC822 FLAGS)")
267
+ if typ != "OK" or not data or not isinstance(data[0], tuple):
268
+ raise RuntimeError(f"Failed to fetch UID {uid}")
269
+ raw: bytes = data[0][1]
270
+ # FLAGS come in a separate item depending on server, normalize
271
+ flags: List[str] = []
272
+ try:
273
+ for item in data:
274
+ if isinstance(item, tuple) and b"FLAGS" in item[0]:
275
+ flags_blob = item[0].decode(errors="ignore")
276
+ start = flags_blob.find("(")
277
+ end = flags_blob.find(")")
278
+ if start >= 0 and end > start:
279
+ flags = flags_blob[start + 1:end].split()
280
+ break
281
+ except Exception:
282
+ flags = []
283
+ return raw, flags
284
+
285
+ def _parse_message(self, uid: str, raw: bytes, flags: List[str]) -> MailItem:
286
+ msg: Message = email.message_from_bytes(raw)
287
+ subject = _decode_header_value(msg.get("Subject"))
288
+ from_list = _parse_addresses(msg.get("From"))
289
+ to_list = _parse_addresses(msg.get("To"))
290
+ cc_list = _parse_addresses(msg.get("Cc"))
291
+ date = _to_local_datetime(msg.get("Date"))
292
+
293
+ text_body: Optional[str] = None
294
+ html_body: Optional[str] = None
295
+ attachments: List[MailPart] = []
296
+
297
+ if msg.is_multipart():
298
+ for part in msg.walk():
299
+ ctype = part.get_content_type()
300
+ dispo = str(part.get("Content-Disposition") or "")
301
+ filename = part.get_filename()
302
+ is_attach = "attachment" in dispo.lower() or bool(filename)
303
+ charset = part.get_content_charset() or "utf-8"
304
+
305
+ if is_attach:
306
+ payload = part.get_payload(decode=True) or b""
307
+ attachments.append(MailPart(ctype, charset, payload, filename=filename, is_attachment=True))
308
+ continue
309
+
310
+ if ctype == "text/plain":
311
+ payload = part.get_payload(decode=True) or b""
312
+ try:
313
+ text_body = payload.decode(charset, errors="replace")
314
+ except Exception:
315
+ text_body = payload.decode("utf-8", errors="replace")
316
+ elif ctype == "text/html":
317
+ payload = part.get_payload(decode=True) or b""
318
+ try:
319
+ html_body = payload.decode(charset, errors="replace")
320
+ except Exception:
321
+ html_body = payload.decode("utf-8", errors="replace")
322
+ else:
323
+ ctype = msg.get_content_type()
324
+ charset = msg.get_content_charset() or "utf-8"
325
+ payload = msg.get_payload(decode=True) or b""
326
+ try:
327
+ body_text = payload.decode(charset, errors="replace")
328
+ except Exception:
329
+ body_text = payload.decode("utf-8", errors="replace")
330
+ if ctype == "text/html":
331
+ html_body = body_text
332
+ else:
333
+ text_body = body_text
334
+
335
+ return MailItem(
336
+ uid=uid,
337
+ subject=subject,
338
+ from_=from_list,
339
+ to=to_list,
340
+ cc=cc_list,
341
+ date=date,
342
+ flags=flags,
343
+ text=text_body,
344
+ html=html_body,
345
+ attachments=attachments,
346
+ raw=raw,
347
+ )
348
+
349
+ def fetch(self, uid: str) -> MailItem:
350
+ raw, flags = self.fetch_raw(uid)
351
+ return self._parse_message(uid, raw, flags)
352
+
353
+ def fetch_many(self, uids: Iterable[str]) -> List[MailItem]:
354
+ out: List[MailItem] = []
355
+ for uid in uids:
356
+ try:
357
+ out.append(self.fetch(uid))
358
+ except Exception:
359
+ continue
360
+ return out
361
+
362
+ # --------- mutate flags and move
363
+
364
+ def add_flags(self, uid: str, *flags: str) -> None:
365
+ self._ensure_selected()
366
+ self.conn.uid("store", uid, "+FLAGS", f"({' '.join(flags)})")
367
+
368
+ def remove_flags(self, uid: str, *flags: str) -> None:
369
+ self._ensure_selected()
370
+ self.conn.uid("store", uid, "-FLAGS", f"({' '.join(flags)})")
371
+
372
+ def mark_seen(self, uid: str) -> None:
373
+ self.add_flags(uid, "\\Seen")
374
+
375
+ def delete(self, uid: str) -> None:
376
+ self.add_flags(uid, "\\Deleted")
377
+
378
+ def expunge(self) -> None:
379
+ self._ensure_selected()
380
+ self.conn.expunge()
381
+
382
+ def move(self, uid_list: Iterable[str], destination: str) -> None:
383
+ """
384
+ Move messages by copying then marking deleted. Works on servers without MOVE.
385
+ """
386
+ self._ensure_selected()
387
+ ids = ",".join(uid_list)
388
+ typ, _ = self.conn.uid("COPY", ids, destination)
389
+ if typ == "OK":
390
+ self.conn.uid("STORE", ids, "+FLAGS", "(\\Deleted)")
391
+
392
+ # --------- exports
393
+
394
+ def save_eml(self, item: MailItem, path: str | Path) -> Path:
395
+ p = Path(path)
396
+ p.write_bytes(item.raw)
397
+ return p
398
+
399
+ def save_attachments(self, item: MailItem, directory: str | Path) -> List[Path]:
400
+ out: List[Path] = []
401
+ d = Path(directory)
402
+ d.mkdir(parents=True, exist_ok=True)
403
+ for part in item.attachments:
404
+ if not part.filename:
405
+ continue
406
+ target = d / part.filename
407
+ target.write_bytes(part.content)
408
+ out.append(target)
409
+ return out
410
+
411
+ # --------- legacy style exports (modernized)
412
+
413
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
414
+ """
415
+ Dump all messages in a mailbox to a single UTF-8 text file.
416
+ """
417
+ self.login()
418
+ self.select(mailbox)
419
+ uids = self.search("ALL")
420
+ lines: List[str] = []
421
+ for uid in uids:
422
+ msg = self.fetch(uid)
423
+ date_str = msg.date.strftime("%a, %d %b %Y %H:%M:%S") if msg.date else ""
424
+ from_str = ", ".join(f"{a.name or ''} <{a.email or ''}>".strip() for a in msg.from_)
425
+ to_str = ", ".join(f"{a.name or ''} <{a.email or ''}>".strip() for a in msg.to)
426
+ body = msg.text or msg.html or ""
427
+ lines.append(
428
+ f"From: {from_str}\nTo: {to_str}\nDate: {date_str}\nSubject: {msg.subject}\n\n{body}\n\n{'-'*60}\n"
429
+ )
430
+ out = Path(path) / "email.txt"
431
+ out.write_text("".join(lines), encoding="utf-8")
432
+ self.logout()
433
+ return out
434
+
435
+ def download_mail_json(
436
+ self,
437
+ lookup: str = "ALL",
438
+ save: bool = False,
439
+ path: str = "",
440
+ file_name: str = "mail.json",
441
+ mailbox: str = "INBOX",
442
+ ) -> str:
443
+ """
444
+ Return JSON of selected messages. Optionally save to disk.
445
+ """
446
+ self.login()
447
+ self.select(mailbox)
448
+ uids = self.search(lookup)
449
+ items = self.fetch_many(uids)
450
+ payload: List[Dict[str, Any]] = []
451
+ for m in items:
452
+ payload.append(
453
+ {
454
+ "uid": m.uid,
455
+ "subject": m.subject,
456
+ "from": [{"name": a.name, "email": a.email} for a in m.from_],
457
+ "to": [{"name": a.name, "email": a.email} for a in m.to],
458
+ "cc": [{"name": a.name, "email": a.email} for a in m.cc],
459
+ "date": m.date.isoformat() if m.date else None,
460
+ "flags": m.flags,
461
+ "text": m.text,
462
+ "html": m.html,
463
+ "attachments": [a.filename for a in m.attachments if a.filename],
464
+ }
465
+ )
466
+ s = json.dumps(payload, ensure_ascii=False, indent=2)
467
+ if save:
468
+ out = Path(path) / file_name
469
+ out.write_text(s, encoding="utf-8")
470
+ self.logout()
471
+ return s
472
+
473
+ def download_mail_eml(self, directory: str = "", lookup: str = "ALL", mailbox: str = "INBOX") -> List[Path]:
474
+ """
475
+ Save each selected message as an .eml file.
476
+ """
477
+ self.login()
478
+ self.select(mailbox)
479
+ uids = self.search(lookup)
480
+ out_paths: List[Path] = []
481
+ target_dir = Path(directory or ".")
482
+ target_dir.mkdir(parents=True, exist_ok=True)
483
+ for i, uid in enumerate(uids):
484
+ item = self.fetch(uid)
485
+ file_name = f"email_{i}_{uid}.eml"
486
+ out = target_dir / file_name
487
+ out.write_bytes(item.raw)
488
+ out_paths.append(out)
489
+ self.logout()
490
+ return out_paths
491
+
492
+ # --------- internal guards
493
+
494
+ def _ensure(self) -> None:
495
+ if not self.conn:
496
+ self.login()
497
+
498
+ def _ensure_selected(self) -> None:
499
+ self._ensure()
500
+ if not self.selected_mailbox:
501
+ self.select("INBOX")