MailToolsBox 1.1.0__tar.gz → 3.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.
Files changed (26) hide show
  1. mailtoolsbox-3.0.0/MailToolsBox/__init__.py +48 -0
  2. mailtoolsbox-3.0.0/MailToolsBox/_version.py +3 -0
  3. mailtoolsbox-3.0.0/MailToolsBox/exceptions.py +40 -0
  4. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox/imapClient.py +226 -63
  5. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox/mailSender.py +319 -129
  6. mailtoolsbox-3.0.0/MailToolsBox/py.typed +0 -0
  7. mailtoolsbox-3.0.0/MailToolsBox/retry.py +108 -0
  8. mailtoolsbox-3.0.0/MailToolsBox/security.py +42 -0
  9. mailtoolsbox-3.0.0/MailToolsBox/templates/example.html +7 -0
  10. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/PKG-INFO +163 -33
  11. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/SOURCES.txt +11 -2
  12. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/requires.txt +11 -1
  13. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/PKG-INFO +163 -33
  14. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/README.md +148 -10
  15. mailtoolsbox-3.0.0/pyproject.toml +88 -0
  16. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/tests/test_imap_agent.py +71 -59
  17. mailtoolsbox-3.0.0/tests/test_integration_smtp.py +111 -0
  18. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/tests/test_mail_sender.py +141 -56
  19. mailtoolsbox-3.0.0/tests/test_retry.py +86 -0
  20. mailtoolsbox-3.0.0/tests/test_session.py +111 -0
  21. mailtoolsbox-1.1.0/MailToolsBox/__init__.py +0 -6
  22. mailtoolsbox-1.1.0/setup.py +0 -75
  23. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/LICENSE.txt +0 -0
  24. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/dependency_links.txt +0 -0
  25. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/MailToolsBox.egg-info/top_level.txt +0 -0
  26. {mailtoolsbox-1.1.0 → mailtoolsbox-3.0.0}/setup.cfg +0 -0
@@ -0,0 +1,48 @@
1
+ """MailToolsBox — modern sync/async SMTP sending and IMAP reading for Python."""
2
+
3
+ from ._version import __version__
4
+ from .exceptions import (
5
+ AuthenticationError,
6
+ ConfigurationError,
7
+ ConnectionError,
8
+ EmailValidationError,
9
+ IMAPError,
10
+ MailToolsBoxError,
11
+ SendError,
12
+ )
13
+ from .imapClient import (
14
+ ImapAgent,
15
+ ImapClient,
16
+ MailAddress,
17
+ MailItem,
18
+ MailPart,
19
+ )
20
+ from .mailSender import EmailSender, SendAgent, SmtpSession
21
+ from .retry import RateLimiter, RetryPolicy
22
+ from .security import SecurityMode
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ # SMTP
27
+ "EmailSender",
28
+ "SmtpSession",
29
+ "SendAgent",
30
+ # IMAP
31
+ "ImapClient",
32
+ "ImapAgent",
33
+ "MailItem",
34
+ "MailAddress",
35
+ "MailPart",
36
+ # Shared
37
+ "SecurityMode",
38
+ "RetryPolicy",
39
+ "RateLimiter",
40
+ # Exceptions
41
+ "MailToolsBoxError",
42
+ "ConfigurationError",
43
+ "ConnectionError",
44
+ "AuthenticationError",
45
+ "SendError",
46
+ "IMAPError",
47
+ "EmailValidationError",
48
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "3.0.0"
@@ -0,0 +1,40 @@
1
+ """Structured exception hierarchy for MailToolsBox.
2
+
3
+ All errors raised by the library derive from :class:`MailToolsBoxError`, so
4
+ callers can catch everything with a single ``except`` while still being able to
5
+ distinguish connection, authentication, send, and validation failures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class MailToolsBoxError(Exception):
12
+ """Base class for every error raised by MailToolsBox."""
13
+
14
+
15
+ class ConfigurationError(MailToolsBoxError):
16
+ """Invalid or missing configuration (bad arguments, env vars, etc.)."""
17
+
18
+
19
+ class EmailValidationError(MailToolsBoxError, ValueError):
20
+ """An email address failed validation/normalization."""
21
+
22
+
23
+ class ConnectionError(MailToolsBoxError):
24
+ """Failed to establish or maintain a server connection."""
25
+
26
+
27
+ class AuthenticationError(MailToolsBoxError):
28
+ """The server rejected the supplied credentials or OAuth2 token."""
29
+
30
+
31
+ class SendError(MailToolsBoxError):
32
+ """A message could not be sent."""
33
+
34
+ def __init__(self, message: str, *, recipient: str | None = None) -> None:
35
+ super().__init__(message)
36
+ self.recipient = recipient
37
+
38
+
39
+ class IMAPError(MailToolsBoxError):
40
+ """An IMAP operation failed."""
@@ -1,31 +1,31 @@
1
1
  from __future__ import annotations
2
2
 
3
- import imaplib
3
+ import base64
4
+ import datetime as dt
4
5
  import email
5
- import ssl
6
- import os
6
+ import imaplib
7
7
  import json
8
- import datetime as dt
9
- import base64
8
+ import logging
9
+ import os
10
+ import re
11
+ import ssl
12
+ from contextlib import contextmanager
10
13
  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
14
  from email.header import decode_header, make_header
15
15
  from email.message import Message
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple
16
18
 
19
+ from .exceptions import AuthenticationError, IMAPError
20
+ from .security import SecurityMode, build_ssl_context
17
21
 
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
22
+ logger = logging.getLogger(__name__)
23
+ logger.addHandler(logging.NullHandler())
25
24
 
26
25
 
27
26
  # ----------------------------- Models -------------------------------
28
27
 
28
+
29
29
  @dataclass
30
30
  class MailAddress:
31
31
  name: Optional[str]
@@ -58,6 +58,7 @@ class MailItem:
58
58
 
59
59
  # --------------------------- Utilities ------------------------------
60
60
 
61
+
61
62
  def _decode_header_value(value: Optional[str]) -> str:
62
63
  if not value:
63
64
  return ""
@@ -66,6 +67,16 @@ def _decode_header_value(value: Optional[str]) -> str:
66
67
  except Exception:
67
68
  return value
68
69
 
70
+
71
+ def _decode_filename(value: Optional[str]) -> str:
72
+ if not value:
73
+ return ""
74
+ try:
75
+ return str(make_header(decode_header(value)))
76
+ except Exception:
77
+ return value
78
+
79
+
69
80
  def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
70
81
  if not value:
71
82
  return []
@@ -75,21 +86,25 @@ def _parse_addresses(value: Optional[str]) -> List[MailAddress]:
75
86
  out.append(MailAddress(_decode_header_value(name) or None, addr or None))
76
87
  return out
77
88
 
89
+
78
90
  def _to_local_datetime(date_hdr: Optional[str]) -> Optional[dt.datetime]:
79
91
  if not date_hdr:
80
92
  return None
81
93
  try:
82
- t = email.utils.parsedate_tz(date_hdr)
83
- if not t:
94
+ dt_obj = email.utils.parsedate_to_datetime(date_hdr)
95
+ if dt_obj is None:
84
96
  return None
85
- timestamp = email.utils.mktime_tz(t)
86
- return dt.datetime.fromtimestamp(timestamp)
97
+ # Assume UTC when timezone missing to keep comparisons consistent.
98
+ if dt_obj.tzinfo is None:
99
+ dt_obj = dt_obj.replace(tzinfo=dt.timezone.utc)
100
+ return dt_obj
87
101
  except Exception:
88
102
  return None
89
103
 
90
104
 
91
105
  # ----------------------------- Client --------------------------------
92
106
 
107
+
93
108
  class ImapClient:
94
109
  """
95
110
  Improved IMAP client with:
@@ -122,11 +137,7 @@ class ImapClient:
122
137
  self.oauth2_access_token = oauth2_access_token
123
138
  self.timeout = timeout
124
139
 
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
140
+ self.ssl_context = build_ssl_context(ssl_context, allow_invalid_certs=allow_invalid_certs)
130
141
 
131
142
  self.conn: Optional[imaplib.IMAP4] = None
132
143
  self.selected_mailbox: Optional[str] = None
@@ -134,7 +145,7 @@ class ImapClient:
134
145
  # --------- factories
135
146
 
136
147
  @classmethod
137
- def from_env(cls) -> "ImapClient":
148
+ def from_env(cls) -> ImapClient:
138
149
  """
139
150
  Required: IMAP_EMAIL, IMAP_SERVER
140
151
  Optional: IMAP_PASSWORD, IMAP_PORT, IMAP_SECURITY, IMAP_OAUTH2_TOKEN, IMAP_ALLOW_INVALID_CERTS
@@ -159,7 +170,7 @@ class ImapClient:
159
170
 
160
171
  # --------- context manager
161
172
 
162
- def __enter__(self) -> "ImapClient":
173
+ def __enter__(self) -> ImapClient:
163
174
  self.login()
164
175
  return self
165
176
 
@@ -180,7 +191,9 @@ class ImapClient:
180
191
 
181
192
  conn = imaplib.IMAP4(self.server_address, self.port)
182
193
  conn.timeout = self.timeout
183
- if mode == SecurityMode.STARTTLS or (mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities):
194
+ if mode == SecurityMode.STARTTLS or (
195
+ mode == SecurityMode.AUTO and "STARTTLS" in conn.capabilities
196
+ ):
184
197
  conn.starttls(self.ssl_context)
185
198
  # capabilities may change after STARTTLS
186
199
  conn.capabilities = conn.capability()[1][0].split() # refresh
@@ -189,17 +202,19 @@ class ImapClient:
189
202
  def _auth(self, conn: imaplib.IMAP4) -> None:
190
203
  if self.oauth2_access_token:
191
204
  # 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")
205
+ raw = f"user={self.email_account}\x01auth=Bearer {self.oauth2_access_token}\x01\x01".encode()
193
206
  xoauth = base64.b64encode(raw).decode("ascii")
194
207
  typ, resp = conn.authenticate("XOAUTH2", lambda _: xoauth)
195
208
  if typ != "OK":
196
- raise RuntimeError(f"XOAUTH2 failed: {resp}")
209
+ raise AuthenticationError(f"XOAUTH2 failed: {resp!r}")
197
210
  return
198
211
  if self.password is None:
199
- raise RuntimeError("No password or OAuth2 token provided for IMAP authentication")
212
+ raise AuthenticationError(
213
+ "No password or OAuth2 token provided for IMAP authentication"
214
+ )
200
215
  typ, resp = conn.login(self.email_account, self.password)
201
216
  if typ != "OK":
202
- raise RuntimeError(f"IMAP login failed: {resp}")
217
+ raise AuthenticationError(f"IMAP login failed: {resp!r}")
203
218
 
204
219
  def login(self) -> None:
205
220
  if self.conn:
@@ -232,8 +247,12 @@ class ImapClient:
232
247
  # format: b'(\\HasNoChildren) "/" "INBOX/Sub"'
233
248
  if not line:
234
249
  continue
235
- parts = line.decode("utf-8", errors="ignore").split(" ")
236
- name = parts[-1].strip('"')
250
+ decoded = line.decode("utf-8", errors="ignore")
251
+ match = re.search(r'"((?:\\.|[^"])*)"$', decoded)
252
+ if match:
253
+ name = match.group(1).replace(r"\"", '"')
254
+ else:
255
+ name = decoded.split(" ", 1)[-1].strip('"')
237
256
  names.append(name)
238
257
  return names
239
258
 
@@ -241,7 +260,7 @@ class ImapClient:
241
260
  self._ensure()
242
261
  typ, _ = self.conn.select(mailbox, readonly=readonly)
243
262
  if typ != "OK":
244
- raise RuntimeError(f"Cannot select mailbox {mailbox}")
263
+ raise IMAPError(f"Cannot select mailbox {mailbox}")
245
264
  self.selected_mailbox = mailbox
246
265
 
247
266
  # --------- search and fetch
@@ -265,7 +284,7 @@ class ImapClient:
265
284
  self._ensure_selected()
266
285
  typ, data = self.conn.uid("fetch", uid, "(RFC822 FLAGS)")
267
286
  if typ != "OK" or not data or not isinstance(data[0], tuple):
268
- raise RuntimeError(f"Failed to fetch UID {uid}")
287
+ raise IMAPError(f"Failed to fetch UID {uid}")
269
288
  raw: bytes = data[0][1]
270
289
  # FLAGS come in a separate item depending on server, normalize
271
290
  flags: List[str] = []
@@ -276,7 +295,7 @@ class ImapClient:
276
295
  start = flags_blob.find("(")
277
296
  end = flags_blob.find(")")
278
297
  if start >= 0 and end > start:
279
- flags = flags_blob[start + 1:end].split()
298
+ flags = flags_blob[start + 1 : end].split()
280
299
  break
281
300
  except Exception:
282
301
  flags = []
@@ -293,18 +312,34 @@ class ImapClient:
293
312
  text_body: Optional[str] = None
294
313
  html_body: Optional[str] = None
295
314
  attachments: List[MailPart] = []
315
+ used_names: Set[str] = set()
296
316
 
297
317
  if msg.is_multipart():
298
318
  for part in msg.walk():
299
319
  ctype = part.get_content_type()
300
320
  dispo = str(part.get("Content-Disposition") or "")
301
- filename = part.get_filename()
321
+ filename_raw = part.get_filename()
322
+ filename = _decode_filename(filename_raw)
323
+ if filename:
324
+ filename = Path(filename).name
325
+ if filename in used_names:
326
+ stem = Path(filename).stem
327
+ suffix = Path(filename).suffix
328
+ counter = 1
329
+ candidate = f"{stem}_{counter}{suffix}"
330
+ while candidate in used_names:
331
+ counter += 1
332
+ candidate = f"{stem}_{counter}{suffix}"
333
+ filename = candidate
334
+ used_names.add(filename)
302
335
  is_attach = "attachment" in dispo.lower() or bool(filename)
303
336
  charset = part.get_content_charset() or "utf-8"
304
337
 
305
338
  if is_attach:
306
339
  payload = part.get_payload(decode=True) or b""
307
- attachments.append(MailPart(ctype, charset, payload, filename=filename, is_attachment=True))
340
+ attachments.append(
341
+ MailPart(ctype, charset, payload, filename=filename, is_attachment=True)
342
+ )
308
343
  continue
309
344
 
310
345
  if ctype == "text/plain":
@@ -355,7 +390,8 @@ class ImapClient:
355
390
  for uid in uids:
356
391
  try:
357
392
  out.append(self.fetch(uid))
358
- except Exception:
393
+ except Exception as exc:
394
+ logger.warning("Failed to fetch UID %s: %s", uid, exc)
359
395
  continue
360
396
  return out
361
397
 
@@ -410,11 +446,23 @@ class ImapClient:
410
446
 
411
447
  # --------- legacy style exports (modernized)
412
448
 
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.
449
+ @contextmanager
450
+ def _managed_session(self) -> Iterator[None]:
451
+ """Ensure a connection for the duration of a one-shot export.
452
+
453
+ Only tears the connection down if *this* call opened it; a connection
454
+ the caller already established (e.g. inside ``with ImapClient(...)``)
455
+ is left open.
416
456
  """
417
- self.login()
457
+ owned = self.conn is None
458
+ self._ensure()
459
+ try:
460
+ yield
461
+ finally:
462
+ if owned:
463
+ self.logout()
464
+
465
+ def _dump_text(self, path: str, mailbox: str) -> Path:
418
466
  self.select(mailbox)
419
467
  uids = self.search("ALL")
420
468
  lines: List[str] = []
@@ -429,21 +477,14 @@ class ImapClient:
429
477
  )
430
478
  out = Path(path) / "email.txt"
431
479
  out.write_text("".join(lines), encoding="utf-8")
432
- self.logout()
433
480
  return out
434
481
 
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()
482
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
483
+ """Dump all messages in a mailbox to a single UTF-8 text file."""
484
+ with self._managed_session():
485
+ return self._dump_text(path, mailbox)
486
+
487
+ def _dump_json(self, lookup: str, save: bool, path: str, file_name: str, mailbox: str) -> str:
447
488
  self.select(mailbox)
448
489
  uids = self.search(lookup)
449
490
  items = self.fetch_many(uids)
@@ -467,14 +508,21 @@ class ImapClient:
467
508
  if save:
468
509
  out = Path(path) / file_name
469
510
  out.write_text(s, encoding="utf-8")
470
- self.logout()
471
511
  return s
472
512
 
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()
513
+ def download_mail_json(
514
+ self,
515
+ lookup: str = "ALL",
516
+ save: bool = False,
517
+ path: str = "",
518
+ file_name: str = "mail.json",
519
+ mailbox: str = "INBOX",
520
+ ) -> str:
521
+ """Return JSON of selected messages. Optionally save to disk."""
522
+ with self._managed_session():
523
+ return self._dump_json(lookup, save, path, file_name, mailbox)
524
+
525
+ def _dump_eml(self, directory: str, lookup: str, mailbox: str) -> List[Path]:
478
526
  self.select(mailbox)
479
527
  uids = self.search(lookup)
480
528
  out_paths: List[Path] = []
@@ -486,9 +534,15 @@ class ImapClient:
486
534
  out = target_dir / file_name
487
535
  out.write_bytes(item.raw)
488
536
  out_paths.append(out)
489
- self.logout()
490
537
  return out_paths
491
538
 
539
+ def download_mail_eml(
540
+ self, directory: str = "", lookup: str = "ALL", mailbox: str = "INBOX"
541
+ ) -> List[Path]:
542
+ """Save each selected message as an .eml file."""
543
+ with self._managed_session():
544
+ return self._dump_eml(directory, lookup, mailbox)
545
+
492
546
  # --------- internal guards
493
547
 
494
548
  def _ensure(self) -> None:
@@ -499,3 +553,112 @@ class ImapClient:
499
553
  self._ensure()
500
554
  if not self.selected_mailbox:
501
555
  self.select("INBOX")
556
+
557
+
558
+ # ----------------------------- Legacy shim -----------------------------
559
+
560
+
561
+ class ImapAgent(ImapClient):
562
+ """
563
+ Backward compatible adapter that mirrors the old ImapAgent API
564
+ on top of the modern ImapClient.
565
+ """
566
+
567
+ def __init__(
568
+ self,
569
+ email_account: str,
570
+ password: Optional[str],
571
+ server_address: str,
572
+ *,
573
+ port: int = 993,
574
+ security_mode: SecurityMode = SecurityMode.SSL,
575
+ oauth2_access_token: Optional[str] = None,
576
+ ssl_context: Optional[ssl.SSLContext] = None,
577
+ allow_invalid_certs: bool = False,
578
+ timeout: int = 30,
579
+ ) -> None:
580
+ super().__init__(
581
+ email_account=email_account,
582
+ password=password,
583
+ server_address=server_address,
584
+ port=port,
585
+ security_mode=security_mode,
586
+ oauth2_access_token=oauth2_access_token,
587
+ ssl_context=ssl_context,
588
+ allow_invalid_certs=allow_invalid_certs,
589
+ timeout=timeout,
590
+ )
591
+ self.mail: Optional[imaplib.IMAP4] = None
592
+
593
+ def login_account(self) -> imaplib.IMAP4:
594
+ self.login()
595
+ self.mail = self.conn
596
+ return self.mail
597
+
598
+ def logout_account(self) -> None:
599
+ self.logout()
600
+ self.mail = None
601
+
602
+ def __enter__(self) -> ImapAgent:
603
+ self.login_account()
604
+ return self
605
+
606
+ def __exit__(self, exc_type, exc, tb) -> None:
607
+ self.logout_account()
608
+
609
+ def _sync_mail_alias(self) -> None:
610
+ if self.mail and self.conn is None:
611
+ self.conn = self.mail
612
+
613
+ def logout(self) -> None:
614
+ super().logout()
615
+ self.mail = None
616
+
617
+ # Legacy semantics: these one-shot exports always close the connection
618
+ # when done (the modern ImapClient versions leave a caller-owned
619
+ # connection open).
620
+
621
+ def download_mail_text(self, path: str = "", mailbox: str = "INBOX") -> Path:
622
+ self._sync_mail_alias()
623
+ self.login()
624
+ try:
625
+ return self._dump_text(path, mailbox)
626
+ finally:
627
+ self.logout()
628
+
629
+ def download_mail_json(
630
+ self,
631
+ lookup: str = "ALL",
632
+ save: bool = False,
633
+ path: str = "",
634
+ file_name: str = "mail.json",
635
+ mailbox: str = "INBOX",
636
+ ) -> str:
637
+ self._sync_mail_alias()
638
+ self.login()
639
+ try:
640
+ return self._dump_json(lookup, save, path, file_name, mailbox)
641
+ finally:
642
+ self.logout()
643
+
644
+ def download_mail_msg(
645
+ self, path: str = "", lookup: str = "ALL", mailbox: str = "INBOX"
646
+ ) -> List[Path]:
647
+ """Save each selected message as a .msg file (wrapper over .eml bytes)."""
648
+ self._sync_mail_alias()
649
+ self.login()
650
+ try:
651
+ self.select(mailbox)
652
+ uids = self.search(lookup)
653
+ out_paths: List[Path] = []
654
+ target_dir = Path(path or ".")
655
+ target_dir.mkdir(parents=True, exist_ok=True)
656
+ for i, uid in enumerate(uids):
657
+ item = self.fetch(uid)
658
+ file_name = f"email_{i}.msg"
659
+ out = target_dir / file_name
660
+ out.write_bytes(item.raw)
661
+ out_paths.append(out)
662
+ return out_paths
663
+ finally:
664
+ self.logout()