mailsuite 2.2.2__tar.gz → 2.3.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,9 +1,9 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: mailsuite
3
- Version: 2.2.2
3
+ Version: 2.3.0
4
4
  Summary: A Python package for retrieving, parsing, and sending emails
5
5
  Project-URL: Homepage, https://github.com/seanthegeek/mailsuite/
6
- Project-URL: Documentation, https://seanthegeek.github.io/mailsuite/
6
+ Project-URL: Documentation, https://seanthegeek.net/mailsuite/
7
7
  Project-URL: Issues, https://github.com/seanthegeek/mailsuite/issues
8
8
  Project-URL: Changelog, https://github.com/seanthegeek/mailsuite/blob/master/CHANGELOG.md
9
9
  Author-email: Sean Whalen <whalenster@gmail.com>
@@ -16,15 +16,20 @@ Classifier: Intended Audience :: Information Technology
16
16
  Classifier: License :: OSI Approved :: Apache Software License
17
17
  Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python :: 3
19
- Requires-Python: >=3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Requires-Python: >=3.10
20
25
  Requires-Dist: authres>=1.2.0
21
- Requires-Dist: cryptography>=41.0.0
26
+ Requires-Dist: cryptography>=50.0.0
22
27
  Requires-Dist: dkimpy>=1.1.0
23
28
  Requires-Dist: dnspython>=2.0.0
24
29
  Requires-Dist: expiringdict==1.2.2
25
30
  Requires-Dist: html2text>=2020.1.16
26
31
  Requires-Dist: imapclient>=3.1.0
27
- Requires-Dist: mail-parser>=4.2.1
32
+ Requires-Dist: mail-parser>=4.6.2
28
33
  Requires-Dist: publicsuffix2>=2.20190812
29
34
  Provides-Extra: all
30
35
  Requires-Dist: azure-identity>=1.15.0; extra == 'all'
@@ -50,6 +55,20 @@ A Python package for retrieving, parsing, and sending emails.
50
55
 
51
56
  ## Features
52
57
 
58
+ - Simplified IMAP client (`mailsuite.imap.IMAPClient`) — usable on its own,
59
+ and the engine behind the mailbox abstraction's IMAP backend
60
+ - Automatic reconnection and retries after dropped connections and timeouts
61
+ - Watch a folder for new messages with `IDLE` callbacks, including periodic
62
+ session refresh
63
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login
64
+ - Always uses `/` as the folder hierarchy separator, converting to the
65
+ server's separator and prepending its namespace automatically, and
66
+ stripping folder-name characters that collide with the separator
67
+ - Works around server quirks across Gmail, Microsoft 365, Exchange,
68
+ Dovecot, and DavMail, including:
69
+ - Gmail / Google Workspace returning an empty `IDLE` response
70
+ - Random Microsoft 365 / Exchange `BAD` / "unexpected response" errors
71
+ - Nonstandard hierarchy separators and namespaces
53
72
  - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
54
73
  - Single `MailboxConnection` interface for IMAP, Microsoft Graph, Gmail,
55
74
  and on-disk Maildir
@@ -58,33 +77,32 @@ A Python package for retrieving, parsing, and sending emails.
58
77
  - Folder management across every backend — create, rename, move, merge,
59
78
  delete, and existence checks, with consistent `FolderExistsError` /
60
79
  `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
80
+ - Watch a folder for new messages — the IMAP `IDLE` command on the IMAP
81
+ backend, polling on the cloud backends
63
82
  - Unified `send_message()` on backends that support sending (Microsoft
64
83
  Graph, Gmail) — IMAP and Maildir users send through
65
84
  `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
76
- - Consistent email parsing
85
+ - Consistent email parsing (`mailsuite.utils`)
86
+ - Parse RFC 822 messages from a string, bytes, or file path into
87
+ consistent dictionaries, with HTML bodies also converted to Markdown
77
88
  - SHA256 hashes of attachments
78
89
  - Parsed `Authentication-Results` and `DKIM-Signature` headers
90
+ - Email address parsing into display name, local part, domain, and
91
+ second-level domain, tolerating noncompliant addresses
92
+ - Check whether a message passed DKIM or DMARC as a trusted domain
93
+ (`from_trusted_domain`)
94
+ - Forward and reverse DNS lookup helpers
79
95
  - Parse Microsoft Outlook `.msg` files using `msgconvert`
80
- - Simplified email creation and sending
96
+ - Simplified email creation and sending (`mailsuite.smtp`)
81
97
  - Easily add attachments, plain text, and HTML
98
+ - Optional DKIM signing of outgoing mail
82
99
  - Uses opportunistic encryption (`STARTTLS`) with SMTP by default
83
- - DKIM signing and verification
100
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login
101
+ - DKIM signing and verification (`mailsuite.dkim`)
84
102
  - Generate RSA keypairs and the matching DNS TXT record
85
103
  - Sign outbound mail with a sensible default header set
86
104
  - Verify one or many `DKIM-Signature` headers on a received message
87
- - ARC (Authenticated Received Chain) sealing and verification
105
+ - ARC (Authenticated Received Chain) sealing and verification (`mailsuite.arc`)
88
106
  - Seal forwarded mail with an ARC set, extending an existing chain
89
107
  - Verify the ARC chain on a received message and read its `cv` result
90
108
 
@@ -7,6 +7,20 @@ A Python package for retrieving, parsing, and sending emails.
7
7
 
8
8
  ## Features
9
9
 
10
+ - Simplified IMAP client (`mailsuite.imap.IMAPClient`) — usable on its own,
11
+ and the engine behind the mailbox abstraction's IMAP backend
12
+ - Automatic reconnection and retries after dropped connections and timeouts
13
+ - Watch a folder for new messages with `IDLE` callbacks, including periodic
14
+ session refresh
15
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login
16
+ - Always uses `/` as the folder hierarchy separator, converting to the
17
+ server's separator and prepending its namespace automatically, and
18
+ stripping folder-name characters that collide with the separator
19
+ - Works around server quirks across Gmail, Microsoft 365, Exchange,
20
+ Dovecot, and DavMail, including:
21
+ - Gmail / Google Workspace returning an empty `IDLE` response
22
+ - Random Microsoft 365 / Exchange `BAD` / "unexpected response" errors
23
+ - Nonstandard hierarchy separators and namespaces
10
24
  - Provider-agnostic mailbox abstraction (`mailsuite.mailbox`)
11
25
  - Single `MailboxConnection` interface for IMAP, Microsoft Graph, Gmail,
12
26
  and on-disk Maildir
@@ -15,33 +29,32 @@ A Python package for retrieving, parsing, and sending emails.
15
29
  - Folder management across every backend — create, rename, move, merge,
16
30
  delete, and existence checks, with consistent `FolderExistsError` /
17
31
  `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
32
+ - Watch a folder for new messages — the IMAP `IDLE` command on the IMAP
33
+ backend, polling on the cloud backends
20
34
  - Unified `send_message()` on backends that support sending (Microsoft
21
35
  Graph, Gmail) — IMAP and Maildir users send through
22
36
  `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
33
- - Consistent email parsing
37
+ - Consistent email parsing (`mailsuite.utils`)
38
+ - Parse RFC 822 messages from a string, bytes, or file path into
39
+ consistent dictionaries, with HTML bodies also converted to Markdown
34
40
  - SHA256 hashes of attachments
35
41
  - Parsed `Authentication-Results` and `DKIM-Signature` headers
42
+ - Email address parsing into display name, local part, domain, and
43
+ second-level domain, tolerating noncompliant addresses
44
+ - Check whether a message passed DKIM or DMARC as a trusted domain
45
+ (`from_trusted_domain`)
46
+ - Forward and reverse DNS lookup helpers
36
47
  - Parse Microsoft Outlook `.msg` files using `msgconvert`
37
- - Simplified email creation and sending
48
+ - Simplified email creation and sending (`mailsuite.smtp`)
38
49
  - Easily add attachments, plain text, and HTML
50
+ - Optional DKIM signing of outgoing mail
39
51
  - Uses opportunistic encryption (`STARTTLS`) with SMTP by default
40
- - DKIM signing and verification
52
+ - Username/password or OAuth2 (XOAUTH2 / OAUTHBEARER) login
53
+ - DKIM signing and verification (`mailsuite.dkim`)
41
54
  - Generate RSA keypairs and the matching DNS TXT record
42
55
  - Sign outbound mail with a sensible default header set
43
56
  - Verify one or many `DKIM-Signature` headers on a received message
44
- - ARC (Authenticated Received Chain) sealing and verification
57
+ - ARC (Authenticated Received Chain) sealing and verification (`mailsuite.arc`)
45
58
  - Seal forwarded mail with an ARC set, extending an existing chain
46
59
  - Verify the ARC chain on a received message and read its `cv` result
47
60
 
@@ -1,3 +1,3 @@
1
1
  """A Python package to simplify receiving, parsing, and sending email"""
2
2
 
3
- __version__ = "2.2.2"
3
+ __version__ = "2.3.0"
@@ -18,7 +18,8 @@ like :mod:`mailsuite.dkim`.
18
18
  """
19
19
 
20
20
  import logging
21
- from typing import Any, Callable, List, Optional, Union
21
+ from collections.abc import Callable
22
+ from typing import Any
22
23
 
23
24
  import dkim as _dkim
24
25
 
@@ -31,14 +32,14 @@ class ARCError(RuntimeError):
31
32
 
32
33
 
33
34
  def seal_email(
34
- message: Union[str, bytes],
35
+ message: str | bytes,
35
36
  selector: str,
36
37
  domain: str,
37
- private_key: Union[str, bytes],
38
+ private_key: str | bytes,
38
39
  authserv_id: str,
39
- signed_headers: Optional[List[str]] = None,
40
- timestamp: Optional[int] = None,
41
- ) -> Union[str, bytes]:
40
+ signed_headers: list[str] | None = None,
41
+ timestamp: int | None = None,
42
+ ) -> str | bytes:
42
43
  """
43
44
  Adds an ARC set (seal) to an email and returns the sealed RFC 822 message
44
45
 
@@ -123,9 +124,9 @@ def seal_email(
123
124
 
124
125
 
125
126
  def verify_arc_chain(
126
- message: Union[str, bytes],
127
+ message: str | bytes,
127
128
  minkey: int = 1024,
128
- dns_func: Optional[Callable[[str], bytes]] = None,
129
+ dns_func: Callable[[str], bytes] | None = None,
129
130
  ) -> dict:
130
131
  """
131
132
  Verifies the ARC chain on an RFC 822 message
@@ -3,7 +3,7 @@
3
3
  import base64
4
4
  import email
5
5
  import logging
6
- from typing import Callable, Optional, Tuple, Union
6
+ from collections.abc import Callable
7
7
 
8
8
  import dkim as _dkim
9
9
  from cryptography.hazmat.primitives import serialization
@@ -58,7 +58,7 @@ def generate_dkim_private_key(key_size: int = 2048) -> str:
58
58
  return pem.decode("ascii")
59
59
 
60
60
 
61
- def get_dkim_public_key(private_key: Union[str, bytes]) -> str:
61
+ def get_dkim_public_key(private_key: str | bytes) -> str:
62
62
  """
63
63
  Derives the DKIM public key from a private key
64
64
 
@@ -74,7 +74,7 @@ def get_dkim_public_key(private_key: Union[str, bytes]) -> str:
74
74
  key_bytes = bytes(private_key)
75
75
  try:
76
76
  key = serialization.load_pem_private_key(key_bytes, password=None)
77
- except Exception as e:
77
+ except Exception as e: # noqa: BLE001
78
78
  raise DKIMError(f"Failed to load private key: {e}")
79
79
  der = key.public_key().public_bytes(
80
80
  encoding=serialization.Encoding.DER,
@@ -83,7 +83,7 @@ def get_dkim_public_key(private_key: Union[str, bytes]) -> str:
83
83
  return base64.b64encode(der).decode("ascii")
84
84
 
85
85
 
86
- def generate_dkim_keypair(key_size: int = 2048) -> Tuple[str, str]:
86
+ def generate_dkim_keypair(key_size: int = 2048) -> tuple[str, str]:
87
87
  """
88
88
  Generates a DKIM RSA keypair
89
89
 
@@ -98,11 +98,11 @@ def generate_dkim_keypair(key_size: int = 2048) -> Tuple[str, str]:
98
98
 
99
99
 
100
100
  def generate_dkim_txt_record(
101
- public_key: Union[str, bytes],
101
+ public_key: str | bytes,
102
102
  selector: str = "default",
103
- domain: Optional[str] = None,
104
- flags: Optional[str] = None,
105
- note: Optional[str] = None,
103
+ domain: str | None = None,
104
+ flags: str | None = None,
105
+ note: str | None = None,
106
106
  ) -> str:
107
107
  """
108
108
  Generates a DKIM TXT record
@@ -134,7 +134,7 @@ def generate_dkim_txt_record(
134
134
  elif "BEGIN" in key_str and "PUBLIC KEY" in key_str:
135
135
  try:
136
136
  pub = serialization.load_pem_public_key(key_str.encode("ascii"))
137
- except Exception as e:
137
+ except Exception as e: # noqa: BLE001
138
138
  raise DKIMError(f"Failed to load public key: {e}")
139
139
  der = pub.public_bytes(
140
140
  encoding=serialization.Encoding.DER,
@@ -159,14 +159,14 @@ def generate_dkim_txt_record(
159
159
 
160
160
 
161
161
  def sign_email(
162
- message: Union[str, bytes],
162
+ message: str | bytes,
163
163
  selector: str,
164
164
  domain: str,
165
- private_key: Union[str, bytes],
166
- additional_headers: Optional[list[str]] = None,
167
- canonicalize: Tuple[bytes, bytes] = (b"relaxed", b"relaxed"),
168
- identity: Optional[str] = None,
169
- ) -> Union[str, bytes]:
165
+ private_key: str | bytes,
166
+ additional_headers: list[str] | None = None,
167
+ canonicalize: tuple[bytes, bytes] = (b"relaxed", b"relaxed"),
168
+ identity: str | None = None,
169
+ ) -> str | bytes:
170
170
  """
171
171
  DKIM-signs an email and returns the signed RFC 822 message
172
172
 
@@ -210,7 +210,7 @@ def sign_email(
210
210
  headers_to_sign.extend(additional_headers)
211
211
 
212
212
  parsed = email.message_from_bytes(message_bytes)
213
- present = {key.lower() for key in parsed.keys()}
213
+ present = {key.lower() for key in parsed}
214
214
 
215
215
  include = [
216
216
  header.encode("ascii")
@@ -243,10 +243,10 @@ def sign_email(
243
243
 
244
244
 
245
245
  def verify_email(
246
- message: Union[str, bytes],
246
+ message: str | bytes,
247
247
  timeout: float = 5.0,
248
248
  minkey: int = 1024,
249
- dns_func: Optional[Callable[[str], bytes]] = None,
249
+ dns_func: Callable[[str], bytes] | None = None,
250
250
  ) -> dict:
251
251
  """
252
252
  Verifies the DKIM signature(s) on an RFC 822 message
@@ -318,7 +318,7 @@ def verify_email(
318
318
  sig_info["error"] = "Signature did not verify"
319
319
  except _dkim.DKIMException as e:
320
320
  sig_info["error"] = str(e)
321
- except Exception as e:
321
+ except Exception as e: # noqa: BLE001
322
322
  sig_info["error"] = f"Verification error: {e}"
323
323
 
324
324
  if sig_info["valid"]:
@@ -1,16 +1,16 @@
1
+ import imaplib
1
2
  import logging
2
- from typing import Callable, Union, List, Dict, Optional, cast
3
3
  import time
4
- import socket
4
+ from collections.abc import Callable
5
5
  from ssl import (
6
6
  CERT_NONE,
7
7
  SSLContext,
8
8
  create_default_context,
9
9
  )
10
+ from typing import cast
10
11
 
11
12
  import imapclient
12
13
  import imapclient.exceptions
13
- import imaplib
14
14
 
15
15
  import mailsuite.utils
16
16
 
@@ -31,7 +31,7 @@ class IMAPClient(imapclient.IMAPClient):
31
31
  """A simplified IMAP client"""
32
32
 
33
33
  def _normalise_folder(
34
- self, folder_name: Union[str, bytes, bytearray, memoryview]
34
+ self, folder_name: str | bytes | bytearray | memoryview
35
35
  ) -> str:
36
36
  """
37
37
  Translate a caller's ``/``-delimited folder path to the server's form.
@@ -82,14 +82,14 @@ class IMAPClient(imapclient.IMAPClient):
82
82
  # of the correct "INBOX/Projects/INBOX/old".
83
83
  if self._path_prefix and folder_name.startswith(self._path_prefix):
84
84
  folder_name = folder_name[len(self._path_prefix):]
85
- if not self._hierarchy_separator == "/":
85
+ if self._hierarchy_separator != "/":
86
86
  # Map the caller's "/" delimiter onto the server's native separator
87
87
  # so "/" works regardless of what the server uses. (Any literal
88
88
  # native-separator characters are dropped first, since "/" is the
89
89
  # delimiter callers are expected to use.)
90
90
  folder_name = folder_name.replace(self._hierarchy_separator, "")
91
91
  folder_name = folder_name.replace("/", self._hierarchy_separator)
92
- folder_name = "{0}{1}".format(self._path_prefix, folder_name)
92
+ folder_name = f"{self._path_prefix}{folder_name}"
93
93
 
94
94
  result = imapclient.IMAPClient._normalise_folder(self, folder_name)
95
95
  if isinstance(result, bytes):
@@ -149,7 +149,7 @@ class IMAPClient(imapclient.IMAPClient):
149
149
  idle_start_time = time.monotonic()
150
150
  self.idle()
151
151
  break
152
- except (KeyError, socket.error, BrokenPipeError, ConnectionResetError):
152
+ except (OSError, KeyError, BrokenPipeError, ConnectionResetError):
153
153
  logger.debug("IMAP error: Connection reset")
154
154
  self.reset_connection()
155
155
  idle_callback(self)
@@ -174,22 +174,22 @@ class IMAPClient(imapclient.IMAPClient):
174
174
  def __init__(
175
175
  self,
176
176
  host: str,
177
- username: Optional[str] = None,
178
- password: Optional[str] = None,
177
+ username: str | None = None,
178
+ password: str | None = None,
179
179
  port: int = 993,
180
180
  ssl: bool = True,
181
- ssl_context: Optional[SSLContext] = None,
181
+ ssl_context: SSLContext | None = None,
182
182
  verify: bool = True,
183
183
  timeout: int = 30,
184
184
  max_retries: int = 4,
185
185
  initial_folder: str = "INBOX",
186
186
  idle_callback=None,
187
187
  idle_timeout: int = 30,
188
- oauth2_token: Optional[str] = None,
189
- oauth2_token_provider: Optional[Callable[[], str]] = None,
188
+ oauth2_token: str | None = None,
189
+ oauth2_token_provider: Callable[[], str] | None = None,
190
190
  oauth2_mechanism: str = "XOAUTH2",
191
- oauth2_vendor: Optional[str] = None,
192
- config_reloading: Optional[Callable[[], bool]] = None,
191
+ oauth2_vendor: str | None = None,
192
+ config_reloading: Callable[[], bool] | None = None,
193
193
  ):
194
194
  """
195
195
  Connects to an IMAP server
@@ -245,29 +245,29 @@ class IMAPClient(imapclient.IMAPClient):
245
245
  "either password or an OAuth2 token / token_provider must be "
246
246
  "provided"
247
247
  )
248
- self._init_args = dict(
249
- host=host,
250
- username=username,
251
- password=password,
252
- port=port,
253
- ssl=ssl,
254
- ssl_context=ssl_context,
255
- verify=verify,
256
- timeout=timeout,
257
- max_retries=max_retries,
258
- initial_folder=initial_folder,
259
- idle_callback=idle_callback,
260
- idle_timeout=idle_timeout,
261
- oauth2_token=oauth2_token,
262
- oauth2_token_provider=oauth2_token_provider,
263
- oauth2_mechanism=oauth2_mechanism,
264
- oauth2_vendor=oauth2_vendor,
265
- )
248
+ self._init_args = {
249
+ "host": host,
250
+ "username": username,
251
+ "password": password,
252
+ "port": port,
253
+ "ssl": ssl,
254
+ "ssl_context": ssl_context,
255
+ "verify": verify,
256
+ "timeout": timeout,
257
+ "max_retries": max_retries,
258
+ "initial_folder": initial_folder,
259
+ "idle_callback": idle_callback,
260
+ "idle_timeout": idle_timeout,
261
+ "oauth2_token": oauth2_token,
262
+ "oauth2_token_provider": oauth2_token_provider,
263
+ "oauth2_mechanism": oauth2_mechanism,
264
+ "oauth2_vendor": oauth2_vendor,
265
+ }
266
266
  self.max_retries = max_retries
267
267
  self.idle_callback = idle_callback
268
268
  self.idle_timeout = idle_timeout
269
269
  self._path_prefix = ""
270
- self._other_namespace_prefixes: List[str] = []
270
+ self._other_namespace_prefixes: list[str] = []
271
271
  self._hierarchy_separator = ""
272
272
  if not ssl:
273
273
  logger.info("Connecting to IMAP over plain text")
@@ -317,7 +317,7 @@ class IMAPClient(imapclient.IMAPClient):
317
317
  personal_namespace = self._namespace.personal
318
318
  if len(personal_namespace) > 0:
319
319
  self._hierarchy_separator = personal_namespace[0][1]
320
- if not personal_namespace[0][0] == "":
320
+ if personal_namespace[0][0] != "":
321
321
  self._path_prefix = personal_namespace[0][0]
322
322
  if type(self._path_prefix) is bytes:
323
323
  self._path_prefix = self._path_prefix.decode("utf-8")
@@ -335,8 +335,8 @@ class IMAPClient(imapclient.IMAPClient):
335
335
  self._namespace = None
336
336
  self.select_folder(initial_folder)
337
337
  except (
338
+ OSError,
338
339
  ConnectionResetError,
339
- socket.error,
340
340
  TimeoutError,
341
341
  imapclient.exceptions.IMAPClientError,
342
342
  ) as error:
@@ -357,8 +357,8 @@ class IMAPClient(imapclient.IMAPClient):
357
357
  logger.info("Reconnecting to IMAP")
358
358
  try:
359
359
  self.shutdown()
360
- except Exception as e:
361
- logger.info("Failed to log out: {0}".format(e.__str__()))
360
+ except Exception as e: # noqa: BLE001
361
+ logger.info(f"Failed to log out: {e.__str__()}")
362
362
  self.__init__(
363
363
  self._init_args["host"], # pyright: ignore[reportArgumentType]
364
364
  self._init_args["username"], # pyright: ignore[reportArgumentType]
@@ -380,7 +380,7 @@ class IMAPClient(imapclient.IMAPClient):
380
380
 
381
381
  def fetch_message(
382
382
  self, msg_uid: int, parse: bool = False, _attempt: int = 1
383
- ) -> Union[str, Dict]:
383
+ ) -> str | dict:
384
384
  """
385
385
  Fetch a message by UID, and optionally parse it
386
386
 
@@ -406,14 +406,12 @@ class IMAPClient(imapclient.IMAPClient):
406
406
  for fetch_cmd, expected_keys in fetch_attempts:
407
407
  try:
408
408
  raw_msg = self.fetch(msg_uid, fetch_cmd)[msg_uid]
409
- except (socket.timeout, imaplib.IMAP4.abort):
409
+ except (TimeoutError, imaplib.IMAP4.abort):
410
410
  _attempt = _attempt + 1
411
411
  if _attempt > self.max_retries:
412
412
  raise MaxRetriesExceeded("Maximum retries exceeded")
413
413
  logger.info(
414
- "Attempt {0} of {1} timed out. Retrying...".format(
415
- _attempt, self.max_retries
416
- )
414
+ f"Attempt {_attempt} of {self.max_retries} timed out. Retrying..."
417
415
  )
418
416
  self.reset_connection()
419
417
  return self.fetch_message(msg_uid, parse=parse, _attempt=_attempt)
@@ -425,7 +423,7 @@ class IMAPClient(imapclient.IMAPClient):
425
423
 
426
424
  # Check if any expected key is in the response
427
425
  for key in expected_keys:
428
- if key in raw_msg.keys():
426
+ if key in raw_msg:
429
427
  msg_key = key
430
428
  break
431
429
 
@@ -459,7 +457,7 @@ class IMAPClient(imapclient.IMAPClient):
459
457
 
460
458
  def delete_messages(
461
459
  self,
462
- messages: Union[List[int], List[str], str, int],
460
+ messages: list[int] | list[str] | str | int,
463
461
  silent: bool = True,
464
462
  _attempt: int = 1,
465
463
  ):
@@ -474,7 +472,7 @@ class IMAPClient(imapclient.IMAPClient):
474
472
  if type(messages) is str or type(messages) is int:
475
473
  messages = [int(messages)]
476
474
  logger.info(
477
- "Deleting message UID(s) {0}".format(
475
+ "Deleting message UID(s) {}".format(
478
476
  ",".join(str(uid) for uid in messages) # pyright: ignore[reportGeneralTypeIssues]
479
477
  )
480
478
  )
@@ -487,14 +485,12 @@ class IMAPClient(imapclient.IMAPClient):
487
485
  imapclient.IMAPClient.expunge(self, messages)
488
486
  else:
489
487
  imapclient.IMAPClient.expunge(self)
490
- except (socket.timeout, imaplib.IMAP4.abort):
488
+ except (TimeoutError, imaplib.IMAP4.abort):
491
489
  _attempt = _attempt + 1
492
490
  if _attempt > self.max_retries:
493
491
  raise MaxRetriesExceeded("Maximum retries exceeded")
494
492
  logger.info(
495
- "Attempt {0} of {1} timed out. Retrying...".format(
496
- _attempt, self.max_retries
497
- )
493
+ f"Attempt {_attempt} of {self.max_retries} timed out. Retrying..."
498
494
  )
499
495
  self.reset_connection()
500
496
  self.delete_messages(messages, silent=silent, _attempt=_attempt)
@@ -508,22 +504,20 @@ class IMAPClient(imapclient.IMAPClient):
508
504
  _attempt: The attempt number
509
505
  """
510
506
  if not self.folder_exists(folder):
511
- logger.info("Creating folder: {0}".format(folder))
507
+ logger.info(f"Creating folder: {folder}")
512
508
  try:
513
509
  imapclient.IMAPClient.create_folder(self, folder)
514
- except (socket.timeout, imaplib.IMAP4.abort):
510
+ except (TimeoutError, imaplib.IMAP4.abort):
515
511
  _attempt = _attempt + 1
516
512
  if _attempt > self.max_retries:
517
513
  raise MaxRetriesExceeded("Maximum retries exceeded")
518
514
  logger.info(
519
- "Attempt {0} of {1} timed out. Retrying...".format(
520
- _attempt, self.max_retries
521
- )
515
+ f"Attempt {_attempt} of {self.max_retries} timed out. Retrying..."
522
516
  )
523
517
  self.reset_connection()
524
518
  self.create_folder(folder, _attempt=_attempt)
525
519
 
526
- def _move_messages(self, msg_uids: Union[int, List[int]], folder_path: str):
520
+ def _move_messages(self, msg_uids: int | list[int], folder_path: str):
527
521
  """
528
522
  Move the emails with the given UIDs to the given folder
529
523
 
@@ -537,7 +531,7 @@ class IMAPClient(imapclient.IMAPClient):
537
531
  for chunk in _chunks(msg_uids, 100):
538
532
  if self._move_supported:
539
533
  logger.info(
540
- "Moving message UID(s) {0} to {1}".format(
534
+ "Moving message UID(s) {} to {}".format(
541
535
  ",".join(str(uid) for uid in chunk), folder_path
542
536
  )
543
537
  )
@@ -546,10 +540,10 @@ class IMAPClient(imapclient.IMAPClient):
546
540
  except imapclient.exceptions.IMAPClientError as e:
547
541
  e = e.__str__().lstrip("b'").rstrip("'").rstrip(".")
548
542
  message = "Error moving message UIDs"
549
- e = "{0} {1}: {2}".format(message, msg_uids, e)
550
- logger.info("IMAP error: {0}".format(e))
543
+ e = f"{message} {msg_uids}: {e}"
544
+ logger.info(f"IMAP error: {e}")
551
545
  logger.info(
552
- "Copying message UID(s) {0} to {1} by copy".format(
546
+ "Copying message UID(s) {} to {} by copy".format(
553
547
  ",".join(str(uid) for uid in chunk), folder_path
554
548
  )
555
549
  )
@@ -557,7 +551,7 @@ class IMAPClient(imapclient.IMAPClient):
557
551
  self.delete_messages(chunk)
558
552
  else:
559
553
  logger.info(
560
- "Moving message UID(s) {0} to {1} by copy".format(
554
+ "Moving message UID(s) {} to {} by copy".format(
561
555
  ",".join(str(uid) for uid in chunk), folder_path
562
556
  )
563
557
  )
@@ -565,7 +559,7 @@ class IMAPClient(imapclient.IMAPClient):
565
559
  self.delete_messages(chunk)
566
560
 
567
561
  def move_messages(
568
- self, msg_uids: Union[int, List[int]], folder_path: str, _attempt: int = 1
562
+ self, msg_uids: int | list[int], folder_path: str, _attempt: int = 1
569
563
  ):
570
564
  """
571
565
  Move the emails with the given UIDs to the given folder
@@ -577,14 +571,12 @@ class IMAPClient(imapclient.IMAPClient):
577
571
  """
578
572
  try:
579
573
  self._move_messages(msg_uids, folder_path)
580
- except (socket.timeout, imaplib.IMAP4.abort):
574
+ except (TimeoutError, imaplib.IMAP4.abort):
581
575
  _attempt = _attempt + 1
582
576
  if _attempt > self.max_retries:
583
577
  raise MaxRetriesExceeded("Maximum retries exceeded")
584
578
  logger.info(
585
- "Attempt {0} of {1} timed out. Retrying...".format(
586
- _attempt, self.max_retries
587
- )
579
+ f"Attempt {_attempt} of {self.max_retries} timed out. Retrying..."
588
580
  )
589
581
  self.reset_connection()
590
582
  self._move_messages(msg_uids, folder_path)