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.
- {mailsuite-2.2.2 → mailsuite-2.3.0}/PKG-INFO +40 -22
- {mailsuite-2.2.2 → mailsuite-2.3.0}/README.md +29 -16
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/__init__.py +1 -1
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/arc.py +9 -8
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/dkim.py +19 -19
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/imap.py +57 -65
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/base.py +20 -15
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/gmail.py +29 -20
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/graph.py +54 -47
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/imap.py +11 -11
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/maildir.py +6 -5
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/smtp.py +26 -25
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/utils.py +94 -86
- {mailsuite-2.2.2 → mailsuite-2.3.0}/pyproject.toml +9 -4
- {mailsuite-2.2.2 → mailsuite-2.3.0}/.gitignore +0 -0
- {mailsuite-2.2.2 → mailsuite-2.3.0}/LICENSE +0 -0
- {mailsuite-2.2.2 → mailsuite-2.3.0}/mailsuite/mailbox/__init__.py +3 -3
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: mailsuite
|
|
3
|
-
Version: 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.
|
|
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
|
-
|
|
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>=
|
|
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
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
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
|
-
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
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
|
-
-
|
|
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
|
|
|
@@ -18,7 +18,8 @@ like :mod:`mailsuite.dkim`.
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import logging
|
|
21
|
-
from
|
|
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:
|
|
35
|
+
message: str | bytes,
|
|
35
36
|
selector: str,
|
|
36
37
|
domain: str,
|
|
37
|
-
private_key:
|
|
38
|
+
private_key: str | bytes,
|
|
38
39
|
authserv_id: str,
|
|
39
|
-
signed_headers:
|
|
40
|
-
timestamp:
|
|
41
|
-
) ->
|
|
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:
|
|
127
|
+
message: str | bytes,
|
|
127
128
|
minkey: int = 1024,
|
|
128
|
-
dns_func:
|
|
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
|
|
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:
|
|
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) ->
|
|
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:
|
|
101
|
+
public_key: str | bytes,
|
|
102
102
|
selector: str = "default",
|
|
103
|
-
domain:
|
|
104
|
-
flags:
|
|
105
|
-
note:
|
|
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:
|
|
162
|
+
message: str | bytes,
|
|
163
163
|
selector: str,
|
|
164
164
|
domain: str,
|
|
165
|
-
private_key:
|
|
166
|
-
additional_headers:
|
|
167
|
-
canonicalize:
|
|
168
|
-
identity:
|
|
169
|
-
) ->
|
|
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
|
|
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:
|
|
246
|
+
message: str | bytes,
|
|
247
247
|
timeout: float = 5.0,
|
|
248
248
|
minkey: int = 1024,
|
|
249
|
-
dns_func:
|
|
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
|
|
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:
|
|
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
|
|
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 = "{
|
|
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 (
|
|
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:
|
|
178
|
-
password:
|
|
177
|
+
username: str | None = None,
|
|
178
|
+
password: str | None = None,
|
|
179
179
|
port: int = 993,
|
|
180
180
|
ssl: bool = True,
|
|
181
|
-
ssl_context:
|
|
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:
|
|
189
|
-
oauth2_token_provider:
|
|
188
|
+
oauth2_token: str | None = None,
|
|
189
|
+
oauth2_token_provider: Callable[[], str] | None = None,
|
|
190
190
|
oauth2_mechanism: str = "XOAUTH2",
|
|
191
|
-
oauth2_vendor:
|
|
192
|
-
config_reloading:
|
|
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 =
|
|
249
|
-
host
|
|
250
|
-
username
|
|
251
|
-
password
|
|
252
|
-
port
|
|
253
|
-
ssl
|
|
254
|
-
ssl_context
|
|
255
|
-
verify
|
|
256
|
-
timeout
|
|
257
|
-
max_retries
|
|
258
|
-
initial_folder
|
|
259
|
-
idle_callback
|
|
260
|
-
idle_timeout
|
|
261
|
-
oauth2_token
|
|
262
|
-
oauth2_token_provider
|
|
263
|
-
oauth2_mechanism
|
|
264
|
-
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:
|
|
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
|
|
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: {
|
|
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
|
-
) ->
|
|
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 (
|
|
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 {
|
|
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
|
|
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:
|
|
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) {
|
|
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 (
|
|
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 {
|
|
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: {
|
|
507
|
+
logger.info(f"Creating folder: {folder}")
|
|
512
508
|
try:
|
|
513
509
|
imapclient.IMAPClient.create_folder(self, folder)
|
|
514
|
-
except (
|
|
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 {
|
|
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:
|
|
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) {
|
|
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 = "{
|
|
550
|
-
logger.info("IMAP error: {
|
|
543
|
+
e = f"{message} {msg_uids}: {e}"
|
|
544
|
+
logger.info(f"IMAP error: {e}")
|
|
551
545
|
logger.info(
|
|
552
|
-
"Copying message UID(s) {
|
|
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) {
|
|
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:
|
|
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 (
|
|
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 {
|
|
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)
|