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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mailsuite
3
- Version: 2.3.2
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/
@@ -1,3 +1,3 @@
1
1
  """A Python package to simplify receiving, parsing, and sending email"""
2
2
 
3
- __version__ = "2.3.2"
3
+ __version__ = "2.3.3"
@@ -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 and b"STARTTLS" in self.capabilities():
285
- logger.info("IMAP server supports STARTTLS ... activating now")
286
- self.starttls(ssl_context=ssl_context)
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
- msg = self._active_folder.get(message_id)
121
- if msg is None:
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 spec: a message is "read" once it has been moved out of
126
- # new/ into cur/ with the "S" (Seen) flag set in its info field.
127
- msg.set_subdir("cur")
128
- msg.add_flag("S")
129
- self._active_folder[message_id] = msg
130
- return msg_str or ""
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
- message_data = self._active_folder.get(message_id)
137
- if message_data is None:
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.add(message_data)
141
- self._active_folder.remove(message_id)
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
- if server.has_extn("starttls"):
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