mailsuite 2.0.2__tar.gz → 2.2.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailsuite
3
- Version: 2.0.2
3
+ Version: 2.2.0
4
4
  Summary: A Python package for retrieving, parsing, and sending emails
5
5
  Project-URL: Homepage, https://github.com/seanthegeek/mailsuite/
6
6
  Project-URL: Documentation, https://seanthegeek.github.io/mailsuite/
@@ -17,6 +17,7 @@ Classifier: License :: OSI Approved :: Apache Software License
17
17
  Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python :: 3
19
19
  Requires-Python: >=3.9
20
+ Requires-Dist: authres>=1.2.0
20
21
  Requires-Dist: cryptography>=41.0.0
21
22
  Requires-Dist: dkimpy>=1.1.0
22
23
  Requires-Dist: dnspython>=2.0.0
@@ -49,38 +50,43 @@ A Python package for retrieving, parsing, and sending emails.
49
50
 
50
51
  ## Features
51
52
 
52
- - Simplified IMAP client
53
- - Retrieve email from any folder
54
- - Create new folders
55
- - Move messages to other folders
56
- - Delete messages
57
- - Monitor folders for new messages using the IMAP ``IDLE`` command
58
- - Always use ``/`` as the folder hierarchy separator, and convert to the
59
- server's hierarchy separator in the background
60
- - Always remove folder name characters that conflict with the server's
61
- hierarchy separators
62
- - Prepend the namespace to the folder path when required
63
- - Automatically reconnect when needed
64
- - Work around quirks in Gmail, Microsoft 365, Exchange, Dovecot, and
65
- DavMail
53
+ - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
54
+ - Single `MailboxConnection` interface for IMAP, Microsoft Graph, Gmail,
55
+ and on-disk Maildir
56
+ - Fetch message identifiers from any folder, retrieve their raw RFC 822
57
+ content, and move or delete messages
58
+ - Folder management across every backend create, rename, move, merge,
59
+ delete, and existence checks, with consistent `FolderExistsError` /
60
+ `FolderNotFoundError` semantics
61
+ - Watch a folder for new messages the IMAP `IDLE` command (with periodic
62
+ session refresh) on the IMAP backend, polling on the cloud backends
63
+ - Unified `send_message()` on backends that support sending (Microsoft
64
+ Graph, Gmail) IMAP and Maildir users send through
65
+ `mailsuite.smtp.send_email`
66
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login for IMAP and SMTP
67
+ - Automatic IMAP reconnection after dropped connections and timeouts
68
+ - Always uses `/` as the folder hierarchy separator, converting to the
69
+ server's separator and prepending its namespace automatically, and
70
+ stripping folder-name characters that collide with the separator
71
+ - Works around backend quirks across Gmail, Microsoft 365, Exchange,
72
+ Dovecot, and DavMail, including:
73
+ - Gmail / Google Workspace returning an empty `IDLE` response
74
+ - Random Microsoft 365 / Exchange `BAD` / "unexpected response" errors
75
+ - Nonstandard hierarchy separators and namespaces
66
76
  - Consistent email parsing
67
77
  - SHA256 hashes of attachments
68
- - Parsed ``Authentication-Results`` and ``DKIM-Signature`` headers
69
- - Parse Microsoft Outlook ``.msg`` files using `msgconvert`
78
+ - Parsed `Authentication-Results` and `DKIM-Signature` headers
79
+ - Parse Microsoft Outlook `.msg` files using `msgconvert`
70
80
  - Simplified email creation and sending
71
81
  - Easily add attachments, plain text, and HTML
72
- - Uses opportunistic encryption (``STARTTLS``) with SMTP by default
82
+ - Uses opportunistic encryption (`STARTTLS`) with SMTP by default
73
83
  - DKIM signing and verification
74
84
  - Generate RSA keypairs and the matching DNS TXT record
75
- - Sign outbound mail with a sensible default header set (with `From`,
76
- `To`, `Cc`, `Subject` oversigned)
85
+ - Sign outbound mail with a sensible default header set
77
86
  - Verify one or many `DKIM-Signature` headers on a received message
78
- - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
79
- - Single `MailboxConnection` interface for IMAP, Microsoft Graph,
80
- Gmail, and on-disk Maildir
81
- - Unified `send_message()` on backends that support sending (Microsoft
82
- Graph, Gmail) — IMAP and Maildir users send through
83
- `mailsuite.smtp.send_email`
87
+ - ARC (Authenticated Received Chain) sealing and verification
88
+ - Seal forwarded mail with an ARC set, extending an existing chain
89
+ - Verify the ARC chain on a received message and read its `cv` result
84
90
 
85
91
  ## Installation
86
92
 
@@ -90,6 +96,11 @@ Base install (IMAP, SMTP, DKIM, Maildir, parsing):
90
96
  pip install mailsuite
91
97
  ```
92
98
 
99
+ If you would like to be able to parse Microsoft Outlook `.msg` files, install
100
+ `msgconvert`. On Debian-based Linux distributions, `msgconvert` can be installed
101
+ via `sudo apt-get install libemail-outlook-message-perl`. Other systems can use
102
+ `cpan -i Email::Outlook::Message`.
103
+
93
104
  The Microsoft Graph and Gmail backends are optional extras — the cloud
94
105
  SDKs aren't pulled in unless you ask for them:
95
106
 
@@ -136,7 +147,7 @@ mailbox, grant `Mail.ReadWrite` and `Mail.Send`.
136
147
 
137
148
  Delegated flows (`DeviceCode`, `UsernamePassword`) targeting a shared
138
149
  mailbox — i.e. when the `mailbox` argument differs from `username` —
139
- use the `.Shared` variants. App-only flows (`ClientSecret`,
150
+ use the `.Shared` variants. App-only flows (`ClientAssertion`, `ClientSecret`,
140
151
  `Certificate`) do not need the `.Shared` variants since application
141
152
  permissions span every mailbox in the tenant (unless restricted by an
142
153
  [Application Access Policy](https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access)).
@@ -7,38 +7,43 @@ A Python package for retrieving, parsing, and sending emails.
7
7
 
8
8
  ## Features
9
9
 
10
- - Simplified IMAP client
11
- - Retrieve email from any folder
12
- - Create new folders
13
- - Move messages to other folders
14
- - Delete messages
15
- - Monitor folders for new messages using the IMAP ``IDLE`` command
16
- - Always use ``/`` as the folder hierarchy separator, and convert to the
17
- server's hierarchy separator in the background
18
- - Always remove folder name characters that conflict with the server's
19
- hierarchy separators
20
- - Prepend the namespace to the folder path when required
21
- - Automatically reconnect when needed
22
- - Work around quirks in Gmail, Microsoft 365, Exchange, Dovecot, and
23
- DavMail
10
+ - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
11
+ - Single `MailboxConnection` interface for IMAP, Microsoft Graph, Gmail,
12
+ and on-disk Maildir
13
+ - Fetch message identifiers from any folder, retrieve their raw RFC 822
14
+ content, and move or delete messages
15
+ - Folder management across every backend create, rename, move, merge,
16
+ delete, and existence checks, with consistent `FolderExistsError` /
17
+ `FolderNotFoundError` semantics
18
+ - Watch a folder for new messages the IMAP `IDLE` command (with periodic
19
+ session refresh) on the IMAP backend, polling on the cloud backends
20
+ - Unified `send_message()` on backends that support sending (Microsoft
21
+ Graph, Gmail) IMAP and Maildir users send through
22
+ `mailsuite.smtp.send_email`
23
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login for IMAP and SMTP
24
+ - Automatic IMAP reconnection after dropped connections and timeouts
25
+ - Always uses `/` as the folder hierarchy separator, converting to the
26
+ server's separator and prepending its namespace automatically, and
27
+ stripping folder-name characters that collide with the separator
28
+ - Works around backend quirks across Gmail, Microsoft 365, Exchange,
29
+ Dovecot, and DavMail, including:
30
+ - Gmail / Google Workspace returning an empty `IDLE` response
31
+ - Random Microsoft 365 / Exchange `BAD` / "unexpected response" errors
32
+ - Nonstandard hierarchy separators and namespaces
24
33
  - Consistent email parsing
25
34
  - SHA256 hashes of attachments
26
- - Parsed ``Authentication-Results`` and ``DKIM-Signature`` headers
27
- - Parse Microsoft Outlook ``.msg`` files using `msgconvert`
35
+ - Parsed `Authentication-Results` and `DKIM-Signature` headers
36
+ - Parse Microsoft Outlook `.msg` files using `msgconvert`
28
37
  - Simplified email creation and sending
29
38
  - Easily add attachments, plain text, and HTML
30
- - Uses opportunistic encryption (``STARTTLS``) with SMTP by default
39
+ - Uses opportunistic encryption (`STARTTLS`) with SMTP by default
31
40
  - DKIM signing and verification
32
41
  - Generate RSA keypairs and the matching DNS TXT record
33
- - Sign outbound mail with a sensible default header set (with `From`,
34
- `To`, `Cc`, `Subject` oversigned)
42
+ - Sign outbound mail with a sensible default header set
35
43
  - Verify one or many `DKIM-Signature` headers on a received message
36
- - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
37
- - Single `MailboxConnection` interface for IMAP, Microsoft Graph,
38
- Gmail, and on-disk Maildir
39
- - Unified `send_message()` on backends that support sending (Microsoft
40
- Graph, Gmail) — IMAP and Maildir users send through
41
- `mailsuite.smtp.send_email`
44
+ - ARC (Authenticated Received Chain) sealing and verification
45
+ - Seal forwarded mail with an ARC set, extending an existing chain
46
+ - Verify the ARC chain on a received message and read its `cv` result
42
47
 
43
48
  ## Installation
44
49
 
@@ -48,6 +53,11 @@ Base install (IMAP, SMTP, DKIM, Maildir, parsing):
48
53
  pip install mailsuite
49
54
  ```
50
55
 
56
+ If you would like to be able to parse Microsoft Outlook `.msg` files, install
57
+ `msgconvert`. On Debian-based Linux distributions, `msgconvert` can be installed
58
+ via `sudo apt-get install libemail-outlook-message-perl`. Other systems can use
59
+ `cpan -i Email::Outlook::Message`.
60
+
51
61
  The Microsoft Graph and Gmail backends are optional extras — the cloud
52
62
  SDKs aren't pulled in unless you ask for them:
53
63
 
@@ -94,7 +104,7 @@ mailbox, grant `Mail.ReadWrite` and `Mail.Send`.
94
104
 
95
105
  Delegated flows (`DeviceCode`, `UsernamePassword`) targeting a shared
96
106
  mailbox — i.e. when the `mailbox` argument differs from `username` —
97
- use the `.Shared` variants. App-only flows (`ClientSecret`,
107
+ use the `.Shared` variants. App-only flows (`ClientAssertion`, `ClientSecret`,
98
108
  `Certificate`) do not need the `.Shared` variants since application
99
109
  permissions span every mailbox in the tenant (unless restricted by an
100
110
  [Application Access Policy](https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access)).
@@ -1,3 +1,3 @@
1
1
  """A Python package to simplify receiving, parsing, and sending email"""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.2.0"
@@ -0,0 +1,207 @@
1
+ """Authenticated Received Chain (ARC) sealing and verification (RFC 8617)
2
+
3
+ ARC lets a sequence of intermediaries (mailing lists, forwarders, gateways)
4
+ record the email authentication results they observed, so that a later
5
+ receiver can trust those results even when SPF/DKIM/DMARC break in transit.
6
+ Each hop adds an *ARC set* of three header fields keyed by an instance
7
+ number (``i=``):
8
+
9
+ * ``ARC-Authentication-Results`` (AAR) — a snapshot of the
10
+ ``Authentication-Results`` this hop produced.
11
+ * ``ARC-Message-Signature`` (AMS) — a DKIM-like signature over the message
12
+ as this hop saw it.
13
+ * ``ARC-Seal`` (AS) — a signature over the ARC header fields, binding the
14
+ chain together and recording its cumulative validity (``cv=``).
15
+
16
+ This module wraps ``dkimpy``'s ARC implementation behind an API shaped
17
+ like :mod:`mailsuite.dkim`.
18
+ """
19
+
20
+ import logging
21
+ from typing import Any, Callable, List, Optional, Union
22
+
23
+ import dkim as _dkim
24
+
25
+ logger = logging.getLogger(__name__)
26
+ logger.addHandler(logging.NullHandler())
27
+
28
+
29
+ class ARCError(RuntimeError):
30
+ """Raised when an ARC error occurs"""
31
+
32
+
33
+ def seal_email(
34
+ message: Union[str, bytes],
35
+ selector: str,
36
+ domain: str,
37
+ private_key: Union[str, bytes],
38
+ authserv_id: str,
39
+ signed_headers: Optional[List[str]] = None,
40
+ timestamp: Optional[int] = None,
41
+ ) -> Union[str, bytes]:
42
+ """
43
+ Adds an ARC set (seal) to an email and returns the sealed RFC 822 message
44
+
45
+ The new ARC set is prepended to the message. If the message already
46
+ carries one or more ARC sets, this adds the next instance and extends
47
+ the chain.
48
+
49
+ The message **must** contain an ``Authentication-Results`` header whose
50
+ authserv-id equals ``authserv_id`` — that is the authentication this hop
51
+ is attesting to, and it is copied into the ``ARC-Authentication-Results``
52
+ header. Per RFC 8617 the chain is sealed only when such results exist. If
53
+ none match — or, when extending an existing chain, the matching results
54
+ record no prior ARC result (``arc=``) to continue from — no ARC set is
55
+ produced and :class:`ARCError` is raised.
56
+
57
+ Args:
58
+ message: An RFC 822 message
59
+ selector: The DKIM selector for the sealing domain
60
+ domain: The sealing (ADMD) domain
61
+ private_key: A PEM-encoded RSA private key
62
+ authserv_id: The authentication-service identifier of this hop (the
63
+ authserv-id used in its ``Authentication-Results`` headers, often
64
+ the receiving host's name). Only ``Authentication-Results``
65
+ headers carrying this id are folded into the seal.
66
+ signed_headers: Header names the ``ARC-Message-Signature`` should
67
+ cover. Defaults to dkimpy's recommended set — the headers present
68
+ in the message that it lists as SHOULD-sign (``From``, ``To``,
69
+ ``Cc``, ``Subject``, ``Date``, ``Message-ID``, the ``List-*``
70
+ headers, etc.), with ``From`` oversigned. ``From`` must be
71
+ included.
72
+ timestamp: The ``t=`` value (epoch seconds) stamped into the AMS and
73
+ AS. Defaults to the current time.
74
+
75
+ Returns: The sealed RFC 822 message. The return type matches the input
76
+ type — ``str`` in, ``str`` out; ``bytes`` in, ``bytes`` out.
77
+
78
+ Raises:
79
+ ARCError: If the message has no matching ``Authentication-Results``
80
+ header (nothing to seal), an existing chain cannot be continued,
81
+ or the inputs are otherwise malformed (e.g. ``From`` is not
82
+ signed).
83
+ """
84
+ if isinstance(message, str):
85
+ message_bytes: bytes = message.encode("utf-8")
86
+ else:
87
+ message_bytes = bytes(message)
88
+
89
+ if isinstance(private_key, str):
90
+ private_key_bytes: bytes = private_key.encode("ascii")
91
+ else:
92
+ private_key_bytes = bytes(private_key)
93
+
94
+ sign_kwargs = {"timestamp": timestamp, "logger": logger}
95
+ if signed_headers is not None:
96
+ sign_kwargs["include_headers"] = [
97
+ header.encode("ascii") for header in signed_headers
98
+ ]
99
+
100
+ try:
101
+ arc_set = _dkim.arc_sign(
102
+ message_bytes,
103
+ selector.encode("ascii"),
104
+ domain.encode("ascii"),
105
+ private_key_bytes,
106
+ authserv_id.encode("ascii"),
107
+ **sign_kwargs,
108
+ )
109
+ except _dkim.DKIMException as e:
110
+ raise ARCError(str(e))
111
+
112
+ if not arc_set:
113
+ raise ARCError(
114
+ f"No ARC set produced: no Authentication-Results header for "
115
+ f"authserv_id {authserv_id!r} was found, or the existing chain "
116
+ f"is terminated"
117
+ )
118
+
119
+ sealed = b"".join(arc_set) + message_bytes
120
+ if isinstance(message, str):
121
+ return sealed.decode("utf-8", errors="replace")
122
+ return sealed
123
+
124
+
125
+ def verify_arc_chain(
126
+ message: Union[str, bytes],
127
+ minkey: int = 1024,
128
+ dns_func: Optional[Callable[[str], bytes]] = None,
129
+ ) -> dict:
130
+ """
131
+ Verifies the ARC chain on an RFC 822 message
132
+
133
+ The chain validation value (``cv``) summarises the whole chain:
134
+
135
+ * ``"pass"`` — every ARC set verified and the chain is intact.
136
+ * ``"none"`` — the message is not ARC sealed.
137
+ * ``"fail"`` — the chain is broken (a signature did not verify, a seal
138
+ reported failure, or an instance reported an invalid status).
139
+
140
+ Per RFC 8617 the most recent ``ARC-Message-Signature`` must validate and
141
+ every ``ARC-Seal`` in the chain must validate for a ``"pass"``.
142
+
143
+ Args:
144
+ message: An RFC 822 message
145
+ minkey: The minimum acceptable RSA key size in bits
146
+ dns_func: An optional function taking a DNS name and returning the
147
+ raw TXT record value as bytes. Useful for testing or for using a
148
+ custom resolver. Defaults to dkimpy's built-in resolver.
149
+
150
+ Returns: A dict with the following keys:
151
+
152
+ - ``valid`` (``bool``): ``True`` only when ``cv`` is ``"pass"``
153
+ - ``cv`` (``str``): the chain validation value (``"pass"``,
154
+ ``"fail"``, or ``"none"``)
155
+ - ``reason`` (``str``): a human-readable explanation of the result
156
+ - ``instances`` (``list``): per-ARC-set results in ascending
157
+ instance order, each a dict with:
158
+
159
+ - ``instance`` (``int``): the ``i=`` instance number
160
+ - ``ams_domain`` (``str``): the AMS ``d=`` signing domain
161
+ - ``ams_selector`` (``str``): the AMS ``s=`` selector
162
+ - ``ams_valid`` (``bool``): whether the AMS verified
163
+ - ``as_domain`` (``str``): the AS ``d=`` signing domain
164
+ - ``as_selector`` (``str``): the AS ``s=`` selector
165
+ - ``as_valid`` (``bool``): whether the AS verified
166
+ - ``cv`` (``str``): the ``cv=`` value recorded in this AS
167
+ """
168
+ if isinstance(message, str):
169
+ message_bytes: bytes = message.encode("utf-8")
170
+ else:
171
+ message_bytes = bytes(message)
172
+
173
+ verify_kwargs = {"minkey": minkey, "logger": logger}
174
+ if dns_func is not None:
175
+ verify_kwargs["dnsfunc"] = dns_func
176
+ cv, results, reason = _dkim.arc_verify(message_bytes, **verify_kwargs)
177
+
178
+ def _decode(value: Any) -> Any:
179
+ if isinstance(value, bytes):
180
+ return value.decode("ascii", errors="replace")
181
+ return value
182
+
183
+ # arc_verify returns CV_Pass/CV_Fail/CV_None (bytes), or Python ``None``
184
+ # when a seal reported failure and terminated the chain — treat that as a
185
+ # failed chain.
186
+ cv_value = "fail" if cv is None else _decode(cv)
187
+
188
+ instances = [
189
+ {
190
+ "instance": result.get("instance"),
191
+ "ams_domain": _decode(result.get("ams-domain")),
192
+ "ams_selector": _decode(result.get("ams-selector")),
193
+ "ams_valid": bool(result.get("ams-valid")),
194
+ "as_domain": _decode(result.get("as-domain")),
195
+ "as_selector": _decode(result.get("as-selector")),
196
+ "as_valid": bool(result.get("as-valid")),
197
+ "cv": _decode(result.get("cv")),
198
+ }
199
+ for result in sorted(results, key=lambda r: r.get("instance", 0))
200
+ ]
201
+
202
+ return {
203
+ "valid": cv_value == "pass",
204
+ "cv": cv_value,
205
+ "reason": reason,
206
+ "instances": instances,
207
+ }
@@ -18,11 +18,11 @@ logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
20
  class MaxRetriesExceeded(RuntimeError):
21
- """Raised when the maximum number of retries in exceeded"""
21
+ """Raised when the maximum number of retries is exceeded"""
22
22
 
23
23
 
24
24
  def _chunks(list_like_object, n: int):
25
- """Yield successive n-sized chunks from l."""
25
+ """Yield successive n-sized chunks from list_like_object."""
26
26
  for i in range(0, len(list_like_object), n):
27
27
  yield list_like_object[i : i + n]
28
28
 
@@ -34,14 +34,20 @@ class IMAPClient(imapclient.IMAPClient):
34
34
  self, folder_name: Union[str, bytes, bytearray, memoryview]
35
35
  ) -> str:
36
36
  """
37
- Returns an appropriate path based on the namespace (if any) and
38
- hierarchy separator
37
+ Translate a caller's ``/``-delimited folder path to the server's form.
38
+
39
+ The point of this method is that callers can always use ``/`` as the
40
+ hierarchy separator, even on servers whose native separator is
41
+ something else (e.g. ``.`` on some Dovecot/Courier setups): it maps
42
+ ``/`` onto the server's native separator and applies the
43
+ personal-namespace prefix, so the same folder paths work unchanged
44
+ across servers.
39
45
 
40
46
  Args:
41
- folder_name: The path to correct
47
+ folder_name: A ``/``-delimited folder path
42
48
 
43
49
  Returns:
44
- A corrected path
50
+ The path using the server's native separator and namespace prefix
45
51
  """
46
52
  if isinstance(folder_name, memoryview):
47
53
  folder_name = folder_name.tobytes()
@@ -69,8 +75,18 @@ class IMAPClient(imapclient.IMAPClient):
69
75
  return result.decode("utf-8", "replace")
70
76
  return str(result)
71
77
 
72
- folder_name = folder_name.replace(self._path_prefix, "")
78
+ # Strip only a leading namespace prefix before re-adding it below. An
79
+ # unanchored replace() would also delete the prefix where it appears
80
+ # inside the path, relocating the folder: with prefix "INBOX/",
81
+ # "Projects/INBOX/old" would normalize to "INBOX/Projects/old" instead
82
+ # of the correct "INBOX/Projects/INBOX/old".
83
+ if self._path_prefix and folder_name.startswith(self._path_prefix):
84
+ folder_name = folder_name[len(self._path_prefix):]
73
85
  if not self._hierarchy_separator == "/":
86
+ # Map the caller's "/" delimiter onto the server's native separator
87
+ # so "/" works regardless of what the server uses. (Any literal
88
+ # native-separator characters are dropped first, since "/" is the
89
+ # delimiter callers are expected to use.)
74
90
  folder_name = folder_name.replace(self._hierarchy_separator, "")
75
91
  folder_name = folder_name.replace("/", self._hierarchy_separator)
76
92
  folder_name = "{0}{1}".format(self._path_prefix, folder_name)
@@ -95,6 +111,9 @@ class IMAPClient(imapclient.IMAPClient):
95
111
  idle_callback(self)
96
112
  idle_start_time = time.monotonic()
97
113
  self.idle()
114
+ # Mark the loop active so a reconnect (reset_connection -> __init__)
115
+ # re-arms this loop in place instead of starting a nested IDLE loop.
116
+ self._idle_running = True
98
117
  while True:
99
118
  try:
100
119
  # Refresh the IDLE session every 5 minutes to stay connected
@@ -113,7 +132,12 @@ class IMAPClient(imapclient.IMAPClient):
113
132
  self.idle()
114
133
  else:
115
134
  for r in responses:
116
- if r[0] != 0 and r[1] == b"RECENT":
135
+ # New mail is signalled by an untagged EXISTS (the
136
+ # new message count); RECENT is optional and many
137
+ # servers never send it, so react to either.
138
+ if r[1] == b"EXISTS" or (
139
+ r[1] == b"RECENT" and r[0] != 0
140
+ ):
117
141
  self.idle_done()
118
142
  idle_callback(self)
119
143
  idle_start_time = time.monotonic()
@@ -122,13 +146,20 @@ class IMAPClient(imapclient.IMAPClient):
122
146
  except (KeyError, socket.error, BrokenPipeError, ConnectionResetError):
123
147
  logger.debug("IMAP error: Connection reset")
124
148
  self.reset_connection()
149
+ idle_callback(self)
150
+ idle_start_time = time.monotonic()
151
+ self.idle()
125
152
  except imapclient.exceptions.IMAPClientError as error:
126
153
  error = error.__str__().lstrip("b'").rstrip("'").rstrip(".")
127
154
  # Workaround for random Exchange/Microsoft 365 IMAP errors
128
155
  if "unexpected response" in error or "BAD" in error:
129
156
  self.reset_connection()
157
+ idle_callback(self)
158
+ idle_start_time = time.monotonic()
159
+ self.idle()
130
160
  except KeyboardInterrupt:
131
161
  break
162
+ self._idle_running = False
132
163
  try:
133
164
  self.idle_done()
134
165
  except BrokenPipeError:
@@ -168,8 +199,7 @@ class IMAPClient(imapclient.IMAPClient):
168
199
  max_retries: The maximum number of retries after a timeout
169
200
  initial_folder: The initial folder to select
170
201
  idle_callback: The function to call when new messages are detected
171
- idle_timeout: Number of seconds to wait for an IDLE
172
- response
202
+ idle_timeout: Number of seconds to wait for an IDLE response
173
203
  oauth2_token: A static OAuth2 access token. For long-running
174
204
  connections (IDLE, reconnects after timeouts) prefer
175
205
  ``oauth2_token_provider`` so a fresh token is fetched on
@@ -305,7 +335,10 @@ class IMAPClient(imapclient.IMAPClient):
305
335
  ) as error:
306
336
  error = error.__str__().lstrip("b'").rstrip("'").rstrip(".")
307
337
  raise imapclient.exceptions.IMAPClientError(error)
308
- if idle_callback is not None:
338
+ # Skip starting IDLE if a loop is already running on this connection:
339
+ # reset_connection() re-runs __init__ from inside the loop, and a
340
+ # nested _start_idle would stack IDLE loops on every reconnect.
341
+ if idle_callback is not None and not getattr(self, "_idle_running", False):
309
342
  self._start_idle(idle_callback, idle_timeout=idle_timeout)
310
343
 
311
344
  def reset_connection(self):
@@ -436,7 +469,13 @@ class IMAPClient(imapclient.IMAPClient):
436
469
  )
437
470
  try:
438
471
  imapclient.IMAPClient.delete_messages(self, messages, silent=silent)
439
- imapclient.IMAPClient.expunge(self, messages)
472
+ # Expunging specific UIDs (UID EXPUNGE) requires the UIDPLUS
473
+ # capability; on servers without it, fall back to a plain EXPUNGE,
474
+ # which removes all messages flagged \\Deleted in the folder.
475
+ if self.has_capability("UIDPLUS"):
476
+ imapclient.IMAPClient.expunge(self, messages)
477
+ else:
478
+ imapclient.IMAPClient.expunge(self)
440
479
  except (socket.timeout, imaplib.IMAP4.abort):
441
480
  _attempt = _attempt + 1
442
481
  if _attempt > self.max_retries:
@@ -503,16 +542,16 @@ class IMAPClient(imapclient.IMAPClient):
503
542
  ",".join(str(uid) for uid in chunk), folder_path
504
543
  )
505
544
  )
506
- self.copy(msg_uids, folder_path)
507
- self.delete_messages(msg_uids)
545
+ self.copy(chunk, folder_path)
546
+ self.delete_messages(chunk)
508
547
  else:
509
548
  logger.info(
510
549
  "Moving message UID(s) {0} to {1} by copy".format(
511
550
  ",".join(str(uid) for uid in chunk), folder_path
512
551
  )
513
552
  )
514
- self.copy(msg_uids, folder_path)
515
- self.delete_messages(msg_uids)
553
+ self.copy(chunk, folder_path)
554
+ self.delete_messages(chunk)
516
555
 
517
556
  def move_messages(
518
557
  self, msg_uids: Union[int, List[int]], folder_path: str, _attempt: int = 1
@@ -11,7 +11,11 @@ but referencing the class will surface a clear error if they aren't.
11
11
 
12
12
  from typing import TYPE_CHECKING
13
13
 
14
- from mailsuite.mailbox.base import MailboxConnection
14
+ from mailsuite.mailbox.base import (
15
+ FolderExistsError,
16
+ FolderNotFoundError,
17
+ MailboxConnection,
18
+ )
15
19
  from mailsuite.mailbox.imap import IMAPConnection
16
20
  from mailsuite.mailbox.maildir import MaildirConnection
17
21
 
@@ -21,6 +25,8 @@ if TYPE_CHECKING:
21
25
 
22
26
  __all__ = [
23
27
  "MailboxConnection",
28
+ "FolderExistsError",
29
+ "FolderNotFoundError",
24
30
  "IMAPConnection",
25
31
  "MaildirConnection",
26
32
  "MSGraphConnection",