mailsuite 2.0.0__tar.gz → 2.0.2__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.4
2
2
  Name: mailsuite
3
- Version: 2.0.0
3
+ Version: 2.0.2
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/
@@ -119,3 +119,30 @@ different cache name can pass it through `token_cache_name=` so existing
119
119
  cached `AuthenticationRecord`s and tokens continue to work — for
120
120
  example, `token_cache_name="parsedmarc"` keeps users authenticated
121
121
  across the migration.
122
+
123
+ ### Microsoft Graph permissions
124
+
125
+ Grant the appropriate Microsoft Graph **API permissions** on the app
126
+ registration based on which `MSGraphConnection` operations you need.
127
+ Combine permissions across rows when you need multiple capabilities —
128
+ e.g., to both read and send mail in a delegated flow against your own
129
+ mailbox, grant `Mail.ReadWrite` and `Mail.Send`.
130
+
131
+ | Use case | Delegated (own mailbox) | Delegated (shared mailbox) | App-only |
132
+ | --- | --- | --- | --- |
133
+ | Read messages only (`fetch_message`, `fetch_messages`) | `Mail.Read` | `Mail.Read.Shared` | `Mail.Read` |
134
+ | Read + modify (mark read, delete, move, create folder) | `Mail.ReadWrite` | `Mail.ReadWrite.Shared` | `Mail.ReadWrite` |
135
+ | Send mail (`send_message`) | `Mail.Send` | `Mail.Send.Shared` | `Mail.Send` |
136
+
137
+ Delegated flows (`DeviceCode`, `UsernamePassword`) targeting a shared
138
+ mailbox — i.e. when the `mailbox` argument differs from `username` —
139
+ use the `.Shared` variants. App-only flows (`ClientSecret`,
140
+ `Certificate`) do not need the `.Shared` variants since application
141
+ permissions span every mailbox in the tenant (unless restricted by an
142
+ [Application Access Policy](https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access)).
143
+
144
+ For delegated flows, `MSGraphConnection` requests `Mail.ReadWrite` (or
145
+ `Mail.ReadWrite.Shared`) at authenticate time, so even read-only
146
+ callers must consent to at least `Mail.ReadWrite`. App-only flows
147
+ authenticate with `https://graph.microsoft.com/.default`, which grants
148
+ whichever permissions the app registration has consented.
@@ -77,3 +77,30 @@ different cache name can pass it through `token_cache_name=` so existing
77
77
  cached `AuthenticationRecord`s and tokens continue to work — for
78
78
  example, `token_cache_name="parsedmarc"` keeps users authenticated
79
79
  across the migration.
80
+
81
+ ### Microsoft Graph permissions
82
+
83
+ Grant the appropriate Microsoft Graph **API permissions** on the app
84
+ registration based on which `MSGraphConnection` operations you need.
85
+ Combine permissions across rows when you need multiple capabilities —
86
+ e.g., to both read and send mail in a delegated flow against your own
87
+ mailbox, grant `Mail.ReadWrite` and `Mail.Send`.
88
+
89
+ | Use case | Delegated (own mailbox) | Delegated (shared mailbox) | App-only |
90
+ | --- | --- | --- | --- |
91
+ | Read messages only (`fetch_message`, `fetch_messages`) | `Mail.Read` | `Mail.Read.Shared` | `Mail.Read` |
92
+ | Read + modify (mark read, delete, move, create folder) | `Mail.ReadWrite` | `Mail.ReadWrite.Shared` | `Mail.ReadWrite` |
93
+ | Send mail (`send_message`) | `Mail.Send` | `Mail.Send.Shared` | `Mail.Send` |
94
+
95
+ Delegated flows (`DeviceCode`, `UsernamePassword`) targeting a shared
96
+ mailbox — i.e. when the `mailbox` argument differs from `username` —
97
+ use the `.Shared` variants. App-only flows (`ClientSecret`,
98
+ `Certificate`) do not need the `.Shared` variants since application
99
+ permissions span every mailbox in the tenant (unless restricted by an
100
+ [Application Access Policy](https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access)).
101
+
102
+ For delegated flows, `MSGraphConnection` requests `Mail.ReadWrite` (or
103
+ `Mail.ReadWrite.Shared`) at authenticate time, so even read-only
104
+ callers must consent to at least `Mail.ReadWrite`. App-only flows
105
+ authenticate with `https://graph.microsoft.com/.default`, which grants
106
+ whichever permissions the app registration has consented.
@@ -1,3 +1,3 @@
1
1
  """A Python package to simplify receiving, parsing, and sending email"""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.0.2"
@@ -1,5 +1,5 @@
1
1
  import logging
2
- from typing import Union, List, Dict, Optional, cast
2
+ from typing import Callable, Union, List, Dict, Optional, cast
3
3
  import time
4
4
  import socket
5
5
  from ssl import (
@@ -58,6 +58,17 @@ class IMAPClient(imapclient.IMAPClient):
58
58
  return str(result)
59
59
 
60
60
  folder_name = folder_name.rstrip("/")
61
+
62
+ # If the path is already inside a non-personal namespace (other-users
63
+ # or shared per RFC 2342), pass it through without applying the
64
+ # personal-namespace prefix. Otherwise paths like "user/colleague/Inbox"
65
+ # would get rewritten to "INBOX/user/colleague/Inbox".
66
+ if any(folder_name.startswith(p) for p in self._other_namespace_prefixes):
67
+ result = imapclient.IMAPClient._normalise_folder(self, folder_name)
68
+ if isinstance(result, bytes):
69
+ return result.decode("utf-8", "replace")
70
+ return str(result)
71
+
61
72
  folder_name = folder_name.replace(self._path_prefix, "")
62
73
  if not self._hierarchy_separator == "/":
63
74
  folder_name = folder_name.replace(self._hierarchy_separator, "")
@@ -126,9 +137,9 @@ class IMAPClient(imapclient.IMAPClient):
126
137
  def __init__(
127
138
  self,
128
139
  host: str,
129
- username: str,
130
- password: str,
131
- port: int = 933,
140
+ username: Optional[str] = None,
141
+ password: Optional[str] = None,
142
+ port: int = 993,
132
143
  ssl: bool = True,
133
144
  ssl_context: Optional[SSLContext] = None,
134
145
  verify: bool = True,
@@ -137,14 +148,18 @@ class IMAPClient(imapclient.IMAPClient):
137
148
  initial_folder: str = "INBOX",
138
149
  idle_callback=None,
139
150
  idle_timeout: int = 30,
151
+ oauth2_token: Optional[str] = None,
152
+ oauth2_token_provider: Optional[Callable[[], str]] = None,
153
+ oauth2_mechanism: str = "XOAUTH2",
154
+ oauth2_vendor: Optional[str] = None,
140
155
  ):
141
156
  """
142
157
  Connects to an IMAP server
143
158
 
144
159
  Args:
145
160
  host: The server hostname or IP address
146
- username: The username
147
- password: The password
161
+ username: The username (or OAuth2 identity / email address)
162
+ password: The password (omit when using OAuth2)
148
163
  port: The port
149
164
  ssl: Use SSL or TLS
150
165
  ssl_context: For more advanced TLS options
@@ -155,6 +170,25 @@ class IMAPClient(imapclient.IMAPClient):
155
170
  idle_callback: The function to call when new messages are detected
156
171
  idle_timeout: Number of seconds to wait for an IDLE
157
172
  response
173
+ oauth2_token: A static OAuth2 access token. For long-running
174
+ connections (IDLE, reconnects after timeouts) prefer
175
+ ``oauth2_token_provider`` so a fresh token is fetched on
176
+ reconnect.
177
+ oauth2_token_provider: A zero-arg callable returning a current
178
+ OAuth2 access token. Invoked on every (re)connect, so the
179
+ callable is responsible for refreshing the token when
180
+ needed.
181
+ oauth2_mechanism: ``"XOAUTH2"`` (default — Gmail/Yahoo/M365) or
182
+ ``"OAUTHBEARER"`` (Gmail's standards-track replacement).
183
+ oauth2_vendor: Optional vendor string required by Yahoo's
184
+ XOAUTH2 implementation.
185
+
186
+ For Google Workspace and Microsoft 365 the higher-level
187
+ :class:`mailsuite.mailbox.GmailConnection` and
188
+ :class:`mailsuite.mailbox.MSGraphConnection` backends are usually
189
+ a better fit — they speak the providers' native APIs and handle
190
+ token refresh end-to-end. Generic IMAP OAuth2 is intended for
191
+ other providers (Yahoo, self-hosted, etc.).
158
192
  """
159
193
 
160
194
  if ssl_context is None:
@@ -162,6 +196,18 @@ class IMAPClient(imapclient.IMAPClient):
162
196
  if verify is False:
163
197
  ssl_context.check_hostname = False
164
198
  ssl_context.verify_mode = CERT_NONE
199
+ using_oauth = (
200
+ oauth2_token is not None or oauth2_token_provider is not None
201
+ )
202
+ if using_oauth and not username:
203
+ raise ValueError(
204
+ "username is required when authenticating with OAuth2"
205
+ )
206
+ if not using_oauth and (username is None or password is None):
207
+ raise ValueError(
208
+ "either password or an OAuth2 token / token_provider must be "
209
+ "provided"
210
+ )
165
211
  self._init_args = dict(
166
212
  host=host,
167
213
  username=username,
@@ -175,11 +221,16 @@ class IMAPClient(imapclient.IMAPClient):
175
221
  initial_folder=initial_folder,
176
222
  idle_callback=idle_callback,
177
223
  idle_timeout=idle_timeout,
224
+ oauth2_token=oauth2_token,
225
+ oauth2_token_provider=oauth2_token_provider,
226
+ oauth2_mechanism=oauth2_mechanism,
227
+ oauth2_vendor=oauth2_vendor,
178
228
  )
179
229
  self.max_retries = max_retries
180
230
  self.idle_callback = idle_callback
181
231
  self.idle_timeout = idle_timeout
182
232
  self._path_prefix = ""
233
+ self._other_namespace_prefixes: List[str] = []
183
234
  self._hierarchy_separator = ""
184
235
  if not ssl:
185
236
  logger.info("Connecting to IMAP over plain text")
@@ -196,7 +247,23 @@ class IMAPClient(imapclient.IMAPClient):
196
247
  if not ssl and b"STARTTLS" in self.capabilities():
197
248
  logger.info("IMAP server supports STARTTLS ... activating now")
198
249
  self.starttls(ssl_context=ssl_context)
199
- self.login(username, password)
250
+ if using_oauth:
251
+ token = (
252
+ oauth2_token_provider()
253
+ if oauth2_token_provider is not None
254
+ else oauth2_token
255
+ )
256
+ if oauth2_mechanism.upper() == "OAUTHBEARER":
257
+ self.oauthbearer_login(username, token)
258
+ else:
259
+ self.oauth2_login(
260
+ cast(str, username),
261
+ cast(str, token),
262
+ mech=oauth2_mechanism,
263
+ vendor=oauth2_vendor,
264
+ )
265
+ else:
266
+ self.login(cast(str, username), cast(str, password))
200
267
  self.server_capabilities = self.capabilities()
201
268
  self._move_supported = b"MOVE" in self.server_capabilities
202
269
  self._idle_supported = b"IDLE" in self.server_capabilities
@@ -217,6 +284,16 @@ class IMAPClient(imapclient.IMAPClient):
217
284
  self._path_prefix = personal_namespace[0][0]
218
285
  if type(self._path_prefix) is bytes:
219
286
  self._path_prefix = self._path_prefix.decode("utf-8")
287
+ # Track non-personal namespace prefixes (other-users, shared)
288
+ # so _normalise_folder can recognise paths that already live
289
+ # in a different namespace and leave them alone.
290
+ for ns in (self._namespace.other, self._namespace.shared):
291
+ for entry in ns or ():
292
+ prefix = entry[0]
293
+ if isinstance(prefix, bytes):
294
+ prefix = prefix.decode("utf-8")
295
+ if prefix:
296
+ self._other_namespace_prefixes.append(prefix)
220
297
  else:
221
298
  self._namespace = None
222
299
  self.select_folder(initial_folder)
@@ -244,13 +321,17 @@ class IMAPClient(imapclient.IMAPClient):
244
321
  self._init_args["password"], # pyright: ignore[reportArgumentType]
245
322
  port=self._init_args["port"], # pyright: ignore[reportArgumentType]
246
323
  ssl=self._init_args["ssl"], # pyright: ignore[reportArgumentType]
247
- ssl_context=self._init_args["ssl_context"],
324
+ ssl_context=self._init_args["ssl_context"], # pyright: ignore[reportArgumentType]
248
325
  verify=self._init_args["verify"], # pyright: ignore[reportArgumentType]
249
326
  timeout=self._init_args["timeout"], # pyright: ignore[reportArgumentType]
250
327
  max_retries=self._init_args["max_retries"], # pyright: ignore[reportArgumentType]
251
328
  initial_folder=self._init_args["initial_folder"], # pyright: ignore[reportArgumentType]
252
329
  idle_callback=self._init_args["idle_callback"],
253
330
  idle_timeout=self._init_args["idle_timeout"], # pyright: ignore[reportArgumentType]
331
+ oauth2_token=self._init_args["oauth2_token"], # pyright: ignore[reportArgumentType]
332
+ oauth2_token_provider=self._init_args["oauth2_token_provider"], # pyright: ignore[reportArgumentType]
333
+ oauth2_mechanism=self._init_args["oauth2_mechanism"], # pyright: ignore[reportArgumentType]
334
+ oauth2_vendor=self._init_args["oauth2_vendor"], # pyright: ignore[reportArgumentType]
254
335
  )
255
336
 
256
337
  def fetch_message(
@@ -147,16 +147,35 @@ def _generate_credential(auth_method: str, token_path: Path, **kwargs):
147
147
  raise RuntimeError(f"Auth method {auth_method} not found")
148
148
 
149
149
 
150
+ _persistent_loop: Optional[asyncio.AbstractEventLoop] = None
151
+
152
+
150
153
  def _run(coro):
151
- """Run a coroutine to completion. Refuses to nest in a running loop."""
154
+ """Run a coroutine to completion on a persistent event loop.
155
+
156
+ Refuses to nest in a running loop. We retain a single persistent
157
+ loop across calls because the Graph SDK's underlying
158
+ ``httpx.AsyncClient`` keeps connection-pool resources bound to the
159
+ loop that issued the first request — closing the loop between calls
160
+ (as ``asyncio.run`` does) invalidates those resources and surfaces
161
+ on the next call as ``RuntimeError: Event loop is closed``. See
162
+ https://github.com/domainaware/parsedmarc/issues/742.
163
+ """
164
+ global _persistent_loop
152
165
  try:
153
166
  asyncio.get_running_loop()
154
167
  except RuntimeError:
155
- return asyncio.run(coro)
156
- raise RuntimeError(
157
- "MSGraphConnection cannot be called from inside a running event loop. "
158
- "Use msgraph.GraphServiceClient directly from async code."
159
- )
168
+ pass
169
+ else:
170
+ raise RuntimeError(
171
+ "MSGraphConnection cannot be called from inside a running event loop. "
172
+ "Use msgraph.GraphServiceClient directly from async code."
173
+ )
174
+
175
+ if _persistent_loop is None or _persistent_loop.is_closed():
176
+ _persistent_loop = asyncio.new_event_loop()
177
+ asyncio.set_event_loop(_persistent_loop)
178
+ return _persistent_loop.run_until_complete(coro)
160
179
 
161
180
 
162
181
  class MSGraphConnection(MailboxConnection):
@@ -168,6 +187,25 @@ class MSGraphConnection(MailboxConnection):
168
187
  ``/users/{mailbox}/sendMail`` with a structured ``Message`` body
169
188
  (Graph automatically saves a copy to Sent Items).
170
189
 
190
+ Required Microsoft Graph **API permissions** on the app registration
191
+ (combine as needed):
192
+
193
+ * Read-only (``fetch_message``, ``fetch_messages``): ``Mail.Read``
194
+ * Read + modify (mark read, delete, move, create folder):
195
+ ``Mail.ReadWrite``
196
+ * Send mail (``send_message``): ``Mail.Send``
197
+
198
+ Delegated flows (``DeviceCode``, ``UsernamePassword``) targeting a
199
+ shared mailbox (i.e. ``mailbox != username``) use the ``.Shared``
200
+ variants — ``Mail.Read.Shared``, ``Mail.ReadWrite.Shared``,
201
+ ``Mail.Send.Shared``. App-only flows (``ClientSecret``,
202
+ ``Certificate``) do not need the ``.Shared`` variants. See the
203
+ README "Microsoft Graph permissions" section for the full mapping.
204
+
205
+ Note: delegated flows always request ``Mail.ReadWrite`` at
206
+ authenticate time, so even read-only callers must consent to at
207
+ least ``Mail.ReadWrite``.
208
+
171
209
  Requires the ``msgraph`` extra::
172
210
 
173
211
  pip install mailsuite[msgraph]
@@ -289,8 +327,7 @@ class MSGraphConnection(MailboxConnection):
289
327
  )
290
328
  logger.debug("Created folder %s", folder_name)
291
329
  except Exception as e:
292
- # 409 / "already exists" is normal — surface other errors
293
- if "already exists" in str(e).lower() or "ErrorFolderExists" in str(e):
330
+ if getattr(e, "response_status_code", None) == 409:
294
331
  logger.debug("Folder %s already exists, skipping", folder_name)
295
332
  return
296
333
  raise
@@ -347,8 +384,6 @@ class MSGraphConnection(MailboxConnection):
347
384
  self.mark_message_read(str(message_id))
348
385
  if raw is None:
349
386
  return ""
350
- if isinstance(raw, str):
351
- return raw
352
387
  return bytes(raw).decode("utf-8", errors="replace")
353
388
 
354
389
  def mark_message_read(self, message_id: str) -> None:
@@ -503,9 +538,11 @@ class MSGraphConnection(MailboxConnection):
503
538
  def _list_folders_filtered(
504
539
  self, folder_name: str, parent_folder_id: Optional[str]
505
540
  ) -> List[MailFolder]:
541
+ # OData string-literal escape: single quotes are doubled.
542
+ escaped = folder_name.replace("'", "''")
506
543
  if parent_folder_id is None:
507
544
  query = MailFoldersRequestBuilder.MailFoldersRequestBuilderGetQueryParameters(
508
- filter=f"displayName eq '{folder_name}'"
545
+ filter=f"displayName eq '{escaped}'"
509
546
  )
510
547
  config = MailFoldersRequestBuilder.MailFoldersRequestBuilderGetRequestConfiguration(
511
548
  query_parameters=query
@@ -517,7 +554,7 @@ class MSGraphConnection(MailboxConnection):
517
554
  )
518
555
  else:
519
556
  child_query = ChildFoldersRequestBuilder.ChildFoldersRequestBuilderGetQueryParameters(
520
- filter=f"displayName eq '{folder_name}'"
557
+ filter=f"displayName eq '{escaped}'"
521
558
  )
522
559
  child_config = ChildFoldersRequestBuilder.ChildFoldersRequestBuilderGetRequestConfiguration(
523
560
  query_parameters=child_query
@@ -32,16 +32,42 @@ class IMAPConnection(MailboxConnection):
32
32
  self,
33
33
  host: str,
34
34
  user: str,
35
- password: str,
35
+ password: Optional[str] = None,
36
36
  port: int = 993,
37
37
  ssl: bool = True,
38
38
  verify: bool = True,
39
39
  timeout: int = 30,
40
40
  max_retries: int = 4,
41
+ oauth2_token: Optional[str] = None,
42
+ oauth2_token_provider: Optional[Callable[[], str]] = None,
43
+ oauth2_mechanism: str = "XOAUTH2",
44
+ oauth2_vendor: Optional[str] = None,
41
45
  ):
46
+ """
47
+ Args:
48
+ host: IMAP server hostname
49
+ user: Username (or OAuth2 identity / email address)
50
+ password: Password (omit when using OAuth2)
51
+ port: Server port
52
+ ssl: Use SSL/TLS
53
+ verify: Verify the server's TLS certificate
54
+ timeout: Per-operation timeout in seconds
55
+ max_retries: Retries after a timeout
56
+ oauth2_token: Static OAuth2 access token
57
+ oauth2_token_provider: Zero-arg callable returning a current
58
+ token. Re-invoked on reconnect, so the callable is
59
+ responsible for refreshing the token. Prefer this over
60
+ ``oauth2_token`` for long-running watch loops.
61
+ oauth2_mechanism: ``"XOAUTH2"`` (default) or ``"OAUTHBEARER"``
62
+ oauth2_vendor: Optional vendor string for Yahoo's XOAUTH2
63
+ """
42
64
  self._username = user
43
65
  self._password = password
44
66
  self._verify = verify
67
+ self._oauth2_token = oauth2_token
68
+ self._oauth2_token_provider = oauth2_token_provider
69
+ self._oauth2_mechanism = oauth2_mechanism
70
+ self._oauth2_vendor = oauth2_vendor
45
71
  self._client = IMAPClient(
46
72
  host,
47
73
  user,
@@ -51,6 +77,10 @@ class IMAPConnection(MailboxConnection):
51
77
  verify=verify,
52
78
  timeout=timeout,
53
79
  max_retries=max_retries,
80
+ oauth2_token=oauth2_token,
81
+ oauth2_token_provider=oauth2_token_provider,
82
+ oauth2_mechanism=oauth2_mechanism,
83
+ oauth2_vendor=oauth2_vendor,
54
84
  )
55
85
 
56
86
  def create_folder(self, folder_name: str) -> None:
@@ -121,6 +151,10 @@ class IMAPConnection(MailboxConnection):
121
151
  verify=self._verify,
122
152
  idle_callback=idle_callback_wrapper,
123
153
  idle_timeout=check_timeout,
154
+ oauth2_token=self._oauth2_token,
155
+ oauth2_token_provider=self._oauth2_token_provider,
156
+ oauth2_mechanism=self._oauth2_mechanism,
157
+ oauth2_vendor=self._oauth2_vendor,
124
158
  )
125
159
  except (timeout, IMAPClientError):
126
160
  logger.warning("IMAP connection timeout. Reconnecting...")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes