mailsuite 2.3.2__tar.gz → 2.3.3__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.3.2 → mailsuite-2.3.3}/PKG-INFO +1 -1
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/__init__.py +1 -1
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/imap.py +27 -3
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/imap.py +13 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/maildir.py +65 -13
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/smtp.py +20 -1
- {mailsuite-2.3.2 → mailsuite-2.3.3}/.gitignore +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/LICENSE +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/README.md +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/arc.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/dkim.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/__init__.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/base.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/gmail.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/mailbox/graph.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/mailsuite/utils.py +0 -0
- {mailsuite-2.3.2 → mailsuite-2.3.3}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mailsuite
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.3
|
|
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.net/mailsuite/
|
|
@@ -190,6 +190,7 @@ class IMAPClient(imapclient.IMAPClient):
|
|
|
190
190
|
oauth2_mechanism: str = "XOAUTH2",
|
|
191
191
|
oauth2_vendor: str | None = None,
|
|
192
192
|
config_reloading: Callable[[], bool] | None = None,
|
|
193
|
+
starttls: bool | None = None,
|
|
193
194
|
):
|
|
194
195
|
"""
|
|
195
196
|
Connects to an IMAP server
|
|
@@ -219,6 +220,15 @@ class IMAPClient(imapclient.IMAPClient):
|
|
|
219
220
|
``"OAUTHBEARER"`` (Gmail's standards-track replacement).
|
|
220
221
|
oauth2_vendor: Optional vendor string required by Yahoo's
|
|
221
222
|
XOAUTH2 implementation.
|
|
223
|
+
starttls: Whether to upgrade a plain-text (``ssl=False``)
|
|
224
|
+
connection with STARTTLS. ``None`` (default) upgrades when
|
|
225
|
+
the server advertises STARTTLS; ``False`` never upgrades, so
|
|
226
|
+
the connection stays plain text even when STARTTLS is
|
|
227
|
+
advertised (credentials are then sent unencrypted);
|
|
228
|
+
``True`` requires the upgrade and raises
|
|
229
|
+
:class:`imapclient.exceptions.IMAPClientError` if the server
|
|
230
|
+
does not advertise STARTTLS. Ignored when ``ssl=True``,
|
|
231
|
+
because the connection is already TLS.
|
|
222
232
|
|
|
223
233
|
For Google Workspace and Microsoft 365 the higher-level
|
|
224
234
|
:class:`mailsuite.mailbox.GmailConnection` and
|
|
@@ -262,6 +272,7 @@ class IMAPClient(imapclient.IMAPClient):
|
|
|
262
272
|
"oauth2_token_provider": oauth2_token_provider,
|
|
263
273
|
"oauth2_mechanism": oauth2_mechanism,
|
|
264
274
|
"oauth2_vendor": oauth2_vendor,
|
|
275
|
+
"starttls": starttls,
|
|
265
276
|
}
|
|
266
277
|
self.max_retries = max_retries
|
|
267
278
|
self.idle_callback = idle_callback
|
|
@@ -281,9 +292,21 @@ class IMAPClient(imapclient.IMAPClient):
|
|
|
281
292
|
timeout=timeout,
|
|
282
293
|
)
|
|
283
294
|
try:
|
|
284
|
-
if not ssl
|
|
285
|
-
|
|
286
|
-
|
|
295
|
+
if not ssl:
|
|
296
|
+
starttls_advertised = b"STARTTLS" in self.capabilities()
|
|
297
|
+
if starttls is False:
|
|
298
|
+
if starttls_advertised:
|
|
299
|
+
logger.info(
|
|
300
|
+
"IMAP server supports STARTTLS, but it is disabled"
|
|
301
|
+
" ... staying in plain text"
|
|
302
|
+
)
|
|
303
|
+
elif starttls_advertised:
|
|
304
|
+
logger.info("IMAP server supports STARTTLS ... activating now")
|
|
305
|
+
self.starttls(ssl_context=ssl_context)
|
|
306
|
+
elif starttls:
|
|
307
|
+
raise imapclient.exceptions.IMAPClientError(
|
|
308
|
+
"STARTTLS required but not supported by the server"
|
|
309
|
+
)
|
|
287
310
|
if using_oauth:
|
|
288
311
|
token = (
|
|
289
312
|
oauth2_token_provider()
|
|
@@ -376,6 +399,7 @@ class IMAPClient(imapclient.IMAPClient):
|
|
|
376
399
|
oauth2_token_provider=self._init_args["oauth2_token_provider"], # pyright: ignore[reportArgumentType]
|
|
377
400
|
oauth2_mechanism=self._init_args["oauth2_mechanism"], # pyright: ignore[reportArgumentType]
|
|
378
401
|
oauth2_vendor=self._init_args["oauth2_vendor"], # pyright: ignore[reportArgumentType]
|
|
402
|
+
starttls=self._init_args["starttls"], # pyright: ignore[reportArgumentType]
|
|
379
403
|
)
|
|
380
404
|
|
|
381
405
|
def fetch_message(
|
|
@@ -42,6 +42,7 @@ class IMAPConnection(MailboxConnection):
|
|
|
42
42
|
oauth2_token_provider: Callable[[], str] | None = None,
|
|
43
43
|
oauth2_mechanism: str = "XOAUTH2",
|
|
44
44
|
oauth2_vendor: str | None = None,
|
|
45
|
+
starttls: bool | None = None,
|
|
45
46
|
):
|
|
46
47
|
"""
|
|
47
48
|
Args:
|
|
@@ -60,6 +61,15 @@ class IMAPConnection(MailboxConnection):
|
|
|
60
61
|
``oauth2_token`` for long-running watch loops.
|
|
61
62
|
oauth2_mechanism: ``"XOAUTH2"`` (default) or ``"OAUTHBEARER"``
|
|
62
63
|
oauth2_vendor: Optional vendor string for Yahoo's XOAUTH2
|
|
64
|
+
starttls: Whether to upgrade a plain-text (``ssl=False``)
|
|
65
|
+
connection with STARTTLS. ``None`` (default) upgrades when
|
|
66
|
+
the server advertises STARTTLS; ``False`` never upgrades, so
|
|
67
|
+
the connection stays plain text even when STARTTLS is
|
|
68
|
+
advertised (credentials are then sent unencrypted);
|
|
69
|
+
``True`` requires the upgrade and raises
|
|
70
|
+
:class:`imapclient.exceptions.IMAPClientError` if the server
|
|
71
|
+
does not advertise STARTTLS. Ignored when ``ssl=True``,
|
|
72
|
+
because the connection is already TLS.
|
|
63
73
|
"""
|
|
64
74
|
self._username = user
|
|
65
75
|
self._password = password
|
|
@@ -68,6 +78,7 @@ class IMAPConnection(MailboxConnection):
|
|
|
68
78
|
self._oauth2_token_provider = oauth2_token_provider
|
|
69
79
|
self._oauth2_mechanism = oauth2_mechanism
|
|
70
80
|
self._oauth2_vendor = oauth2_vendor
|
|
81
|
+
self._starttls = starttls
|
|
71
82
|
self._client = IMAPClient(
|
|
72
83
|
host,
|
|
73
84
|
user,
|
|
@@ -81,6 +92,7 @@ class IMAPConnection(MailboxConnection):
|
|
|
81
92
|
oauth2_token_provider=oauth2_token_provider,
|
|
82
93
|
oauth2_mechanism=oauth2_mechanism,
|
|
83
94
|
oauth2_vendor=oauth2_vendor,
|
|
95
|
+
starttls=starttls,
|
|
84
96
|
)
|
|
85
97
|
|
|
86
98
|
def create_folder(self, folder_name: str) -> None:
|
|
@@ -175,6 +187,7 @@ class IMAPConnection(MailboxConnection):
|
|
|
175
187
|
oauth2_mechanism=self._oauth2_mechanism,
|
|
176
188
|
oauth2_vendor=self._oauth2_vendor,
|
|
177
189
|
config_reloading=config_reloading,
|
|
190
|
+
starttls=self._starttls,
|
|
178
191
|
)
|
|
179
192
|
except (TimeoutError, IMAPClientError):
|
|
180
193
|
logger.warning("IMAP connection timeout. Reconnecting...")
|
|
@@ -117,28 +117,80 @@ class MaildirConnection(MailboxConnection):
|
|
|
117
117
|
return list(self._active_folder.keys())
|
|
118
118
|
|
|
119
119
|
def fetch_message(self, message_id: Any, **kwargs: Any) -> str:
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
"""
|
|
121
|
+
Return the message's stored bytes decoded as UTF-8 (undecodable bytes
|
|
122
|
+
replaced), the same decoding
|
|
123
|
+
``mailsuite.imap.IMAPClient.fetch_message(parse=False)``
|
|
124
|
+
applies to the IMAP backend's raw fetch. The file is not parsed or
|
|
125
|
+
re-serialized, so a message ``email`` can parse but not generate
|
|
126
|
+
(e.g. a ``\\x1c`` in a nested header, or deep ``multipart`` nesting)
|
|
127
|
+
is returned as-is. ``Maildir.get_bytes`` converts ``os.linesep`` to
|
|
128
|
+
``\\n``, which is a no-op on POSIX. Returns ``""`` for an unknown
|
|
129
|
+
``message_id``.
|
|
130
|
+
|
|
131
|
+
With ``mark_read=True`` the file is renamed into ``cur/`` with the
|
|
132
|
+
``S`` (Seen) flag added to its info suffix, which is what the Maildir
|
|
133
|
+
spec defines a flag change to be; the content is untouched.
|
|
134
|
+
"""
|
|
135
|
+
folder = self._active_folder
|
|
136
|
+
try:
|
|
137
|
+
msg_str = folder.get_bytes(message_id).decode("utf-8", "replace")
|
|
138
|
+
except KeyError:
|
|
122
139
|
return ""
|
|
123
|
-
msg_str = msg.as_string()
|
|
124
140
|
if kwargs.get("mark_read"):
|
|
125
|
-
# Maildir
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
# mailbox.Maildir has no public way to get a message's on-disk
|
|
142
|
+
# name (or its own path) without parsing the message; _lookup(),
|
|
143
|
+
# _path, and _toc are what its own get_bytes/remove/__setitem__
|
|
144
|
+
# use. _lookup() only rescans a stale name when _refresh() sees
|
|
145
|
+
# the directory as changed, so record the new name in _toc the
|
|
146
|
+
# way CPython 3.13+'s Maildir.set_info() does after its rename.
|
|
147
|
+
subpath = folder._lookup(message_id) # pyright: ignore[reportAttributeAccessIssue]
|
|
148
|
+
name = os.path.basename(subpath)
|
|
149
|
+
flags = mailbox.MaildirMessage()
|
|
150
|
+
if folder.colon in name:
|
|
151
|
+
flags.set_info(name.split(folder.colon)[-1])
|
|
152
|
+
flags.add_flag("S")
|
|
153
|
+
new_subpath = os.path.join(
|
|
154
|
+
"cur", message_id + folder.colon + flags.get_info()
|
|
155
|
+
)
|
|
156
|
+
os.rename(
|
|
157
|
+
os.path.join(folder._path, subpath),
|
|
158
|
+
os.path.join(folder._path, new_subpath),
|
|
159
|
+
)
|
|
160
|
+
folder._toc[message_id] = new_subpath # pyright: ignore[reportAttributeAccessIssue]
|
|
161
|
+
return msg_str
|
|
131
162
|
|
|
132
163
|
def delete_message(self, message_id: Any) -> None:
|
|
133
164
|
self._active_folder.remove(message_id)
|
|
134
165
|
|
|
135
166
|
def move_message(self, message_id: Any, folder_name: str) -> None:
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
"""
|
|
168
|
+
Move the message's file into the same subdirectory (``new``/``cur``)
|
|
169
|
+
of ``folder_name``, keeping its filename, flags, and bytes, instead of
|
|
170
|
+
re-serializing it through ``Maildir.add``. Maildir++ folders live
|
|
171
|
+
under the same root, so this is a same-filesystem rename; if a folder
|
|
172
|
+
is a separate mount, ``os.rename`` raises ``OSError`` (``EXDEV``).
|
|
173
|
+
Raises ``mailbox.ExternalClashError`` rather than overwriting if the
|
|
174
|
+
destination already holds a message with the same key (including a
|
|
175
|
+
move into the active folder itself).
|
|
176
|
+
"""
|
|
177
|
+
folder = self._active_folder
|
|
178
|
+
try:
|
|
179
|
+
subpath = folder._lookup(message_id) # pyright: ignore[reportAttributeAccessIssue]
|
|
180
|
+
except KeyError:
|
|
138
181
|
return
|
|
139
182
|
dest = self._get_folder(folder_name)
|
|
140
|
-
dest
|
|
141
|
-
|
|
183
|
+
if message_id in dest:
|
|
184
|
+
raise mailbox.ExternalClashError(
|
|
185
|
+
f"Folder {folder_name} already contains message {message_id}"
|
|
186
|
+
)
|
|
187
|
+
os.rename(
|
|
188
|
+
os.path.join(folder._path, subpath),
|
|
189
|
+
os.path.join(dest._path, subpath),
|
|
190
|
+
)
|
|
191
|
+
# Keep both tables of contents current; see fetch_message's mark_read.
|
|
192
|
+
folder._toc.pop(message_id, None) # pyright: ignore[reportAttributeAccessIssue]
|
|
193
|
+
dest._toc[message_id] = subpath # pyright: ignore[reportAttributeAccessIssue]
|
|
142
194
|
|
|
143
195
|
def keepalive(self) -> None:
|
|
144
196
|
return
|
|
@@ -62,6 +62,7 @@ def send_email(
|
|
|
62
62
|
dkim_selector: str | None = None,
|
|
63
63
|
dkim_domain: str | None = None,
|
|
64
64
|
dkim_additional_headers: list[str] | None = None,
|
|
65
|
+
starttls: bool | None = None,
|
|
65
66
|
):
|
|
66
67
|
"""
|
|
67
68
|
Send an email using a SMTP relay
|
|
@@ -102,6 +103,15 @@ def send_email(
|
|
|
102
103
|
``dkim_domain`` is not)
|
|
103
104
|
dkim_additional_headers: Additional header names to include in the
|
|
104
105
|
DKIM signature. Headers not present in the message are skipped.
|
|
106
|
+
starttls: Whether to upgrade a plain-text
|
|
107
|
+
(``require_encryption=False``) connection with STARTTLS.
|
|
108
|
+
``None`` (default) upgrades when the server advertises
|
|
109
|
+
STARTTLS; ``False`` never upgrades, so the connection stays
|
|
110
|
+
plain text even when STARTTLS is advertised (credentials are
|
|
111
|
+
then sent unencrypted); ``True`` requires the upgrade and raises
|
|
112
|
+
:class:`SMTPError` if the server does not advertise STARTTLS.
|
|
113
|
+
Ignored when ``require_encryption=True``, because the
|
|
114
|
+
connection is already TLS.
|
|
105
115
|
"""
|
|
106
116
|
|
|
107
117
|
using_oauth = oauth2_token is not None or oauth2_token_provider is not None
|
|
@@ -150,9 +160,18 @@ def send_email(
|
|
|
150
160
|
server = smtplib.SMTP(host, port=port)
|
|
151
161
|
server.connect(host, port)
|
|
152
162
|
server.ehlo_or_helo_if_needed()
|
|
153
|
-
|
|
163
|
+
starttls_advertised = server.has_extn("starttls")
|
|
164
|
+
if starttls is False:
|
|
165
|
+
if starttls_advertised:
|
|
166
|
+
logger.info(
|
|
167
|
+
"SMTP server supports STARTTLS, but it is disabled"
|
|
168
|
+
" ... staying in plain text"
|
|
169
|
+
)
|
|
170
|
+
elif starttls_advertised:
|
|
154
171
|
server.starttls(context=ssl_context)
|
|
155
172
|
server.ehlo()
|
|
173
|
+
elif starttls:
|
|
174
|
+
raise SMTPError("STARTTLS required but not supported by the server")
|
|
156
175
|
else:
|
|
157
176
|
logger.warning(
|
|
158
177
|
"SMTP server does not support STARTTLS. Proceeding in plain text!"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|