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.
- {mailsuite-2.0.2 → mailsuite-2.2.0}/PKG-INFO +38 -27
- {mailsuite-2.0.2 → mailsuite-2.2.0}/README.md +36 -26
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/__init__.py +1 -1
- mailsuite-2.2.0/mailsuite/arc.py +207 -0
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/imap.py +55 -16
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/mailbox/__init__.py +7 -1
- mailsuite-2.2.0/mailsuite/mailbox/base.py +288 -0
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/mailbox/gmail.py +86 -2
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/mailbox/graph.py +143 -10
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/mailbox/imap.py +22 -3
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/mailbox/maildir.py +32 -0
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/smtp.py +68 -11
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/utils.py +46 -50
- {mailsuite-2.0.2 → mailsuite-2.2.0}/pyproject.toml +1 -0
- mailsuite-2.0.2/mailsuite/mailbox/base.py +0 -96
- {mailsuite-2.0.2 → mailsuite-2.2.0}/.gitignore +0 -0
- {mailsuite-2.0.2 → mailsuite-2.2.0}/LICENSE +0 -0
- {mailsuite-2.0.2 → mailsuite-2.2.0}/mailsuite/dkim.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsuite
|
|
3
|
-
Version: 2.0
|
|
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
|
-
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
69
|
-
- Parse Microsoft Outlook
|
|
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 (
|
|
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
|
|
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
|
-
-
|
|
79
|
-
-
|
|
80
|
-
|
|
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
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
27
|
-
- Parse Microsoft Outlook
|
|
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 (
|
|
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
|
|
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
|
-
-
|
|
37
|
-
-
|
|
38
|
-
|
|
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)).
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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:
|
|
47
|
+
folder_name: A ``/``-delimited folder path
|
|
42
48
|
|
|
43
49
|
Returns:
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
507
|
-
self.delete_messages(
|
|
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(
|
|
515
|
-
self.delete_messages(
|
|
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
|
|
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",
|