ergon-framework-python 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl
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.
- ergon/__init__.py +1 -1
- ergon/connector/__init__.py +54 -0
- ergon/connector/outlook/__init__.py +67 -0
- ergon/connector/outlook/async_connector.py +198 -0
- ergon/connector/outlook/async_service.py +168 -0
- ergon/connector/outlook/connector.py +198 -0
- ergon/connector/outlook/models.py +315 -0
- ergon/connector/outlook/service.py +410 -0
- ergon/connector/outlook/utils.py +118 -0
- ergon/connector/rabbitmq/async_service.py +61 -18
- ergon/task/mixins/consumer.py +34 -22
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/METADATA +47 -3
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/RECORD +17 -10
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/WHEEL +0 -0
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/entry_points.txt +0 -0
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {ergon_framework_python-0.2.2.dist-info → ergon_framework_python-0.2.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from ..transaction import Transaction
|
|
10
|
+
from .models import (
|
|
11
|
+
OutlookEmailAddress,
|
|
12
|
+
OutlookFlagStatus,
|
|
13
|
+
OutlookGraphClient,
|
|
14
|
+
OutlookMessageQuery,
|
|
15
|
+
OutlookProducerConfig,
|
|
16
|
+
OutlookSendMessagePayload,
|
|
17
|
+
OutlookWellKnownFolder,
|
|
18
|
+
)
|
|
19
|
+
from .utils import message_to_transaction, normalize_send_payload
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_OUTLOOK_IMPORT_ERROR = "Install with: pip install ergon-framework-python[outlook]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _graph_error_detail(response: requests.Response) -> str:
|
|
27
|
+
try:
|
|
28
|
+
payload = response.json()
|
|
29
|
+
except ValueError:
|
|
30
|
+
return (response.text or response.reason or "").strip()
|
|
31
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
32
|
+
if isinstance(error, dict):
|
|
33
|
+
message = error.get("message") or error.get("code")
|
|
34
|
+
if message:
|
|
35
|
+
return str(message)
|
|
36
|
+
return (response.reason or "").strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _permission_hint(method: str, path: str) -> str:
|
|
40
|
+
target = f"{method} {path}".casefold()
|
|
41
|
+
if any(token in target for token in ("/sendmail", "/reply", "/replyall", "/forward")):
|
|
42
|
+
return "This operation requires the Mail.Send application permission."
|
|
43
|
+
if method.upper() in {"PATCH", "DELETE"} or "/move" in target:
|
|
44
|
+
return "This operation requires the Mail.ReadWrite application permission."
|
|
45
|
+
return "This operation requires the Mail.Read application permission."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _raise_graph_error(method: str, path: str, response: requests.Response) -> None:
|
|
49
|
+
if response.status_code == 403:
|
|
50
|
+
detail = _graph_error_detail(response)
|
|
51
|
+
suffix = f" Graph: {detail}" if detail else ""
|
|
52
|
+
raise requests.HTTPError(
|
|
53
|
+
f"403 Forbidden from Microsoft Graph. {_permission_hint(method, path)}{suffix}",
|
|
54
|
+
response=response,
|
|
55
|
+
)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_msal_app_class():
|
|
60
|
+
try:
|
|
61
|
+
from msal import ConfidentialClientApplication
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
raise ImportError(_OUTLOOK_IMPORT_ERROR) from exc
|
|
64
|
+
return ConfidentialClientApplication
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OutlookGraphService:
|
|
68
|
+
"""Low-level Microsoft Graph mail service using application credentials."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
client: OutlookGraphClient,
|
|
73
|
+
session: Optional[requests.Session] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.client = client
|
|
76
|
+
self._session = session or requests.Session()
|
|
77
|
+
self._msal_app: Any = None
|
|
78
|
+
self._access_token: Optional[str] = None
|
|
79
|
+
self._next_link: Optional[str] = None
|
|
80
|
+
self._exhausted = False
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def user_email(self) -> str:
|
|
84
|
+
return self.client.user_email
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self._session.close()
|
|
88
|
+
|
|
89
|
+
def reset_pagination(self) -> None:
|
|
90
|
+
self._next_link = None
|
|
91
|
+
self._exhausted = False
|
|
92
|
+
|
|
93
|
+
def _authenticate(self, *, force: bool = False) -> str:
|
|
94
|
+
if self._access_token and not force:
|
|
95
|
+
return self._access_token
|
|
96
|
+
if self._msal_app is None or force:
|
|
97
|
+
app_class = _get_msal_app_class()
|
|
98
|
+
self._msal_app = app_class(
|
|
99
|
+
client_id=self.client.client_id,
|
|
100
|
+
client_credential=self.client.client_secret,
|
|
101
|
+
authority=self.client.authority,
|
|
102
|
+
)
|
|
103
|
+
token_response = self._msal_app.acquire_token_for_client(scopes=self.client.scopes)
|
|
104
|
+
access_token = token_response.get("access_token")
|
|
105
|
+
if not isinstance(access_token, str) or not access_token:
|
|
106
|
+
description = token_response.get("error_description") or token_response.get("error") or "unknown error"
|
|
107
|
+
raise RuntimeError(f"Microsoft Graph authentication failed: {description}")
|
|
108
|
+
self._access_token = access_token
|
|
109
|
+
return access_token
|
|
110
|
+
|
|
111
|
+
def _headers(self, *, eventual_consistency: bool = False) -> Dict[str, str]:
|
|
112
|
+
headers = {
|
|
113
|
+
"Authorization": f"Bearer {self._authenticate()}",
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
}
|
|
117
|
+
if eventual_consistency:
|
|
118
|
+
headers["ConsistencyLevel"] = "eventual"
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
def _url(self, path: str) -> str:
|
|
122
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
123
|
+
base = self.client.graph_base_url.rstrip("/")
|
|
124
|
+
if path != base and not path.startswith(f"{base}/"):
|
|
125
|
+
raise ValueError("Microsoft Graph pagination URL is outside graph_base_url")
|
|
126
|
+
return path
|
|
127
|
+
return f"{self.client.graph_base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
128
|
+
|
|
129
|
+
def _request(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
params: Optional[Dict[str, Any]] = None,
|
|
134
|
+
json: Optional[Dict[str, Any]] = None,
|
|
135
|
+
eventual_consistency: bool = False,
|
|
136
|
+
) -> requests.Response:
|
|
137
|
+
response = self._session.request(
|
|
138
|
+
method,
|
|
139
|
+
self._url(path),
|
|
140
|
+
headers=self._headers(eventual_consistency=eventual_consistency),
|
|
141
|
+
params=params,
|
|
142
|
+
json=json,
|
|
143
|
+
timeout=self.client.timeout_sec,
|
|
144
|
+
)
|
|
145
|
+
if response.status_code == 401:
|
|
146
|
+
self._authenticate(force=True)
|
|
147
|
+
response = self._session.request(
|
|
148
|
+
method,
|
|
149
|
+
self._url(path),
|
|
150
|
+
headers=self._headers(eventual_consistency=eventual_consistency),
|
|
151
|
+
params=params,
|
|
152
|
+
json=json,
|
|
153
|
+
timeout=self.client.timeout_sec,
|
|
154
|
+
)
|
|
155
|
+
if response.status_code >= 400:
|
|
156
|
+
_raise_graph_error(method, path, response)
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
def _user_path(self) -> str:
|
|
160
|
+
return f"users/{quote(self.user_email, safe='')}"
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _response_data(response: requests.Response) -> Dict[str, Any]:
|
|
164
|
+
if response.status_code == 204 or not response.content:
|
|
165
|
+
return {}
|
|
166
|
+
data = response.json()
|
|
167
|
+
return data if isinstance(data, dict) else {}
|
|
168
|
+
|
|
169
|
+
def list_messages(
|
|
170
|
+
self,
|
|
171
|
+
query: OutlookMessageQuery,
|
|
172
|
+
limit: int,
|
|
173
|
+
next_link: Optional[str] = None,
|
|
174
|
+
use_internal_pagination: bool = True,
|
|
175
|
+
) -> Dict[str, Any]:
|
|
176
|
+
if use_internal_pagination and self._exhausted:
|
|
177
|
+
return {"data": [], "next_link": None}
|
|
178
|
+
|
|
179
|
+
cursor = next_link if next_link is not None else (self._next_link if use_internal_pagination else None)
|
|
180
|
+
if cursor:
|
|
181
|
+
response = self._request("GET", cursor, eventual_consistency=query.requires_eventual_consistency)
|
|
182
|
+
else:
|
|
183
|
+
folder = quote(query.folder_id, safe="")
|
|
184
|
+
response = self._request(
|
|
185
|
+
"GET",
|
|
186
|
+
f"{self._user_path()}/mailFolders/{folder}/messages",
|
|
187
|
+
params=query.to_query_params(top=limit),
|
|
188
|
+
eventual_consistency=query.requires_eventual_consistency,
|
|
189
|
+
)
|
|
190
|
+
body = self._response_data(response)
|
|
191
|
+
messages = body.get("value") or []
|
|
192
|
+
if not isinstance(messages, list):
|
|
193
|
+
messages = []
|
|
194
|
+
cursor = body.get("@odata.nextLink")
|
|
195
|
+
cursor = cursor if isinstance(cursor, str) and cursor else None
|
|
196
|
+
if use_internal_pagination:
|
|
197
|
+
self._next_link = cursor
|
|
198
|
+
self._exhausted = cursor is None
|
|
199
|
+
return {"data": messages, "next_link": cursor}
|
|
200
|
+
|
|
201
|
+
def find_message(self, message_id: str, *, select: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
202
|
+
params = {"$select": ",".join(select)} if select else None
|
|
203
|
+
response = self._request(
|
|
204
|
+
"GET",
|
|
205
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}",
|
|
206
|
+
params=params,
|
|
207
|
+
)
|
|
208
|
+
return self._response_data(response)
|
|
209
|
+
|
|
210
|
+
def list_attachments(self, message_id: str, *, download_content: bool = False) -> List[Dict[str, Any]]:
|
|
211
|
+
path: Optional[str] = f"{self._user_path()}/messages/{quote(message_id, safe='')}/attachments"
|
|
212
|
+
attachments: List[Dict[str, Any]] = []
|
|
213
|
+
while path:
|
|
214
|
+
response = self._request("GET", path)
|
|
215
|
+
body = self._response_data(response)
|
|
216
|
+
for item in body.get("value") or []:
|
|
217
|
+
if not isinstance(item, dict):
|
|
218
|
+
continue
|
|
219
|
+
if item.get("@odata.type") != "#microsoft.graph.fileAttachment":
|
|
220
|
+
continue
|
|
221
|
+
attachment = {
|
|
222
|
+
"id": item.get("id"),
|
|
223
|
+
"name": item.get("name"),
|
|
224
|
+
"content_type": item.get("contentType"),
|
|
225
|
+
"size": item.get("size"),
|
|
226
|
+
"is_inline": item.get("isInline", False),
|
|
227
|
+
}
|
|
228
|
+
if download_content and item.get("contentBytes"):
|
|
229
|
+
attachment["content"] = base64.b64decode(item["contentBytes"])
|
|
230
|
+
attachments.append(attachment)
|
|
231
|
+
next_link = body.get("@odata.nextLink")
|
|
232
|
+
path = next_link if isinstance(next_link, str) and next_link else None
|
|
233
|
+
return attachments
|
|
234
|
+
|
|
235
|
+
def save_attachment(
|
|
236
|
+
self,
|
|
237
|
+
attachment: Dict[str, Any],
|
|
238
|
+
destination: str | Path,
|
|
239
|
+
overwrite: bool = False,
|
|
240
|
+
) -> Path:
|
|
241
|
+
name = Path(str(attachment.get("name") or "")).name
|
|
242
|
+
content = attachment.get("content")
|
|
243
|
+
if not name or not isinstance(content, bytes):
|
|
244
|
+
raise ValueError("Attachment requires a safe name and downloaded byte content")
|
|
245
|
+
|
|
246
|
+
directory = Path(destination)
|
|
247
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
path = directory / name
|
|
249
|
+
if path.exists() and not overwrite:
|
|
250
|
+
stem, suffix = path.stem, path.suffix
|
|
251
|
+
counter = 1
|
|
252
|
+
while path.exists():
|
|
253
|
+
path = directory / f"{stem}_{counter}{suffix}"
|
|
254
|
+
counter += 1
|
|
255
|
+
path.write_bytes(content)
|
|
256
|
+
return path
|
|
257
|
+
|
|
258
|
+
def fetch_items(
|
|
259
|
+
self,
|
|
260
|
+
query: OutlookMessageQuery,
|
|
261
|
+
limit: int,
|
|
262
|
+
download_attachments: bool = False,
|
|
263
|
+
) -> List[Transaction]:
|
|
264
|
+
result = self.list_messages(query, limit)
|
|
265
|
+
transactions: List[Transaction] = []
|
|
266
|
+
for message in result["data"]:
|
|
267
|
+
attachments: List[Dict[str, Any]] = []
|
|
268
|
+
if message.get("hasAttachments"):
|
|
269
|
+
attachments = self.list_attachments(
|
|
270
|
+
str(message.get("id", "")),
|
|
271
|
+
download_content=download_attachments,
|
|
272
|
+
)
|
|
273
|
+
transactions.append(message_to_transaction(message, self.user_email, attachments))
|
|
274
|
+
return transactions
|
|
275
|
+
|
|
276
|
+
def find_message_transaction(
|
|
277
|
+
self,
|
|
278
|
+
message_id: str,
|
|
279
|
+
select: Optional[List[str]] = None,
|
|
280
|
+
download_attachments: bool = False,
|
|
281
|
+
) -> Transaction:
|
|
282
|
+
message = self.find_message(message_id, select=select)
|
|
283
|
+
attachments: List[Dict[str, Any]] = []
|
|
284
|
+
if message.get("hasAttachments"):
|
|
285
|
+
attachments = self.list_attachments(message_id, download_content=download_attachments)
|
|
286
|
+
return message_to_transaction(message, self.user_email, attachments)
|
|
287
|
+
|
|
288
|
+
def get_messages_count(self, query: OutlookMessageQuery, *, max_pages: Optional[int] = None) -> int:
|
|
289
|
+
count = 0
|
|
290
|
+
pages = 0
|
|
291
|
+
next_link: Optional[str] = None
|
|
292
|
+
while True:
|
|
293
|
+
result = self.list_messages(
|
|
294
|
+
query,
|
|
295
|
+
limit=1000,
|
|
296
|
+
next_link=next_link,
|
|
297
|
+
use_internal_pagination=False,
|
|
298
|
+
)
|
|
299
|
+
batch = result["data"]
|
|
300
|
+
count += len(batch)
|
|
301
|
+
pages += 1
|
|
302
|
+
next_link = result["next_link"]
|
|
303
|
+
if not batch or not next_link:
|
|
304
|
+
break
|
|
305
|
+
if max_pages is not None and pages >= max_pages:
|
|
306
|
+
break
|
|
307
|
+
return count
|
|
308
|
+
|
|
309
|
+
def send_message(
|
|
310
|
+
self,
|
|
311
|
+
payload: OutlookSendMessagePayload,
|
|
312
|
+
producer_config: Optional[OutlookProducerConfig] = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
config = producer_config or OutlookProducerConfig()
|
|
315
|
+
self._request(
|
|
316
|
+
"POST",
|
|
317
|
+
f"{self._user_path()}/sendMail",
|
|
318
|
+
json={
|
|
319
|
+
"message": normalize_send_payload(payload),
|
|
320
|
+
"saveToSentItems": config.save_to_sent_items,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def update_message(self, message_id: str, request_body: Dict[str, Any]) -> Dict[str, Any]:
|
|
325
|
+
response = self._request(
|
|
326
|
+
"PATCH",
|
|
327
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}",
|
|
328
|
+
json=request_body,
|
|
329
|
+
)
|
|
330
|
+
return self._response_data(response)
|
|
331
|
+
|
|
332
|
+
def mark_as_read(self, message_id: str) -> Dict[str, Any]:
|
|
333
|
+
return self.update_message(message_id, {"isRead": True})
|
|
334
|
+
|
|
335
|
+
def mark_as_unread(self, message_id: str) -> Dict[str, Any]:
|
|
336
|
+
return self.update_message(message_id, {"isRead": False})
|
|
337
|
+
|
|
338
|
+
def set_flag(
|
|
339
|
+
self,
|
|
340
|
+
message_id: str,
|
|
341
|
+
status: OutlookFlagStatus | str = OutlookFlagStatus.FLAGGED,
|
|
342
|
+
) -> Dict[str, Any]:
|
|
343
|
+
flag_status = OutlookFlagStatus(status)
|
|
344
|
+
return self.update_message(message_id, {"flag": {"flagStatus": flag_status.value}})
|
|
345
|
+
|
|
346
|
+
def set_categories(self, message_id: str, categories: List[str]) -> Dict[str, Any]:
|
|
347
|
+
normalized = list(dict.fromkeys(category.strip() for category in categories if category.strip()))
|
|
348
|
+
return self.update_message(message_id, {"categories": normalized})
|
|
349
|
+
|
|
350
|
+
def move_message(self, message_id: str, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
|
|
351
|
+
destination = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
|
|
352
|
+
response = self._request(
|
|
353
|
+
"POST",
|
|
354
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}/move",
|
|
355
|
+
json={"destinationId": destination},
|
|
356
|
+
)
|
|
357
|
+
return self._response_data(response)
|
|
358
|
+
|
|
359
|
+
def delete_message(self, message_id: str) -> None:
|
|
360
|
+
self._request(
|
|
361
|
+
"DELETE",
|
|
362
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def reply(self, message_id: str, *, comment: str = "") -> None:
|
|
366
|
+
self._request(
|
|
367
|
+
"POST",
|
|
368
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}/reply",
|
|
369
|
+
json={"comment": comment},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def reply_all(self, message_id: str, *, comment: str = "") -> None:
|
|
373
|
+
self._request(
|
|
374
|
+
"POST",
|
|
375
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}/replyAll",
|
|
376
|
+
json={"comment": comment},
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def forward(
|
|
380
|
+
self,
|
|
381
|
+
message_id: str,
|
|
382
|
+
to: List[OutlookEmailAddress],
|
|
383
|
+
comment: str = "",
|
|
384
|
+
) -> None:
|
|
385
|
+
self._request(
|
|
386
|
+
"POST",
|
|
387
|
+
f"{self._user_path()}/messages/{quote(message_id, safe='')}/forward",
|
|
388
|
+
json={
|
|
389
|
+
"comment": comment,
|
|
390
|
+
"toRecipients": [address.to_graph() for address in to],
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def list_mail_folders(self, *, include_hidden: bool = False) -> List[Dict[str, Any]]:
|
|
395
|
+
response = self._request(
|
|
396
|
+
"GET",
|
|
397
|
+
f"{self._user_path()}/mailFolders",
|
|
398
|
+
params={"includeHiddenFolders": str(include_hidden).lower()},
|
|
399
|
+
)
|
|
400
|
+
body = self._response_data(response)
|
|
401
|
+
folders = body.get("value") or []
|
|
402
|
+
return folders if isinstance(folders, list) else []
|
|
403
|
+
|
|
404
|
+
def get_mail_folder(self, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
|
|
405
|
+
value = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
|
|
406
|
+
response = self._request(
|
|
407
|
+
"GET",
|
|
408
|
+
f"{self._user_path()}/mailFolders/{quote(value, safe='')}",
|
|
409
|
+
)
|
|
410
|
+
return self._response_data(response)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
from ..transaction import Transaction
|
|
4
|
+
from .models import (
|
|
5
|
+
OutlookAckActionConfig,
|
|
6
|
+
OutlookConsumerConfig,
|
|
7
|
+
OutlookMessageQuery,
|
|
8
|
+
OutlookNackActionConfig,
|
|
9
|
+
OutlookSendMessageInput,
|
|
10
|
+
OutlookSendMessagePayload,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def merge_query(
|
|
15
|
+
base: Optional[OutlookConsumerConfig],
|
|
16
|
+
overrides: Optional[OutlookMessageQuery] = None,
|
|
17
|
+
**kwargs: Any,
|
|
18
|
+
) -> OutlookMessageQuery:
|
|
19
|
+
query_fields = set(OutlookMessageQuery.model_fields)
|
|
20
|
+
merged: Dict[str, Any] = {}
|
|
21
|
+
if base is not None:
|
|
22
|
+
merged.update({key: value for key, value in base.model_dump(exclude_none=True).items() if key in query_fields})
|
|
23
|
+
if overrides is not None:
|
|
24
|
+
dumped = {
|
|
25
|
+
key: value
|
|
26
|
+
for key, value in overrides.model_dump(exclude_none=True, exclude_unset=True).items()
|
|
27
|
+
if key in query_fields
|
|
28
|
+
}
|
|
29
|
+
if "search" in dumped:
|
|
30
|
+
merged.pop("filter", None)
|
|
31
|
+
if "filter" in dumped:
|
|
32
|
+
merged.pop("search", None)
|
|
33
|
+
merged.update(dumped)
|
|
34
|
+
for key, value in kwargs.items():
|
|
35
|
+
if key not in query_fields or value is None:
|
|
36
|
+
continue
|
|
37
|
+
if key == "search":
|
|
38
|
+
merged.pop("filter", None)
|
|
39
|
+
if key == "filter":
|
|
40
|
+
merged.pop("search", None)
|
|
41
|
+
merged[key] = value
|
|
42
|
+
return OutlookMessageQuery.model_validate(merged)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _graph_email(value: Any) -> Optional[str]:
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
return None
|
|
48
|
+
address = value.get("emailAddress")
|
|
49
|
+
if not isinstance(address, dict):
|
|
50
|
+
return None
|
|
51
|
+
email = address.get("address")
|
|
52
|
+
return email if isinstance(email, str) and email else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _graph_emails(values: Any) -> list[str]:
|
|
56
|
+
if not isinstance(values, list):
|
|
57
|
+
return []
|
|
58
|
+
emails: list[str] = []
|
|
59
|
+
for item in values:
|
|
60
|
+
email = _graph_email(item)
|
|
61
|
+
if email:
|
|
62
|
+
emails.append(email)
|
|
63
|
+
return emails
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def message_to_transaction(
|
|
67
|
+
message: Dict[str, Any],
|
|
68
|
+
user_email: str,
|
|
69
|
+
attachments: Optional[list[Dict[str, Any]]] = None,
|
|
70
|
+
) -> Transaction:
|
|
71
|
+
attachment_items = attachments or message.get("attachments") or []
|
|
72
|
+
raw_body = message.get("body")
|
|
73
|
+
body = raw_body if isinstance(raw_body, dict) else {}
|
|
74
|
+
return Transaction(
|
|
75
|
+
id=str(message.get("id", "")),
|
|
76
|
+
payload=message,
|
|
77
|
+
metadata={
|
|
78
|
+
"user_email": user_email,
|
|
79
|
+
"subject": message.get("subject"),
|
|
80
|
+
"from_email": _graph_email(message.get("from")),
|
|
81
|
+
"to_emails": _graph_emails(message.get("toRecipients")),
|
|
82
|
+
"received_at": message.get("receivedDateTime"),
|
|
83
|
+
"body_preview": message.get("bodyPreview"),
|
|
84
|
+
"body": body.get("content"),
|
|
85
|
+
"body_type": body.get("contentType"),
|
|
86
|
+
"conversation_id": message.get("conversationId"),
|
|
87
|
+
"internet_message_id": message.get("internetMessageId"),
|
|
88
|
+
"parent_folder_id": message.get("parentFolderId"),
|
|
89
|
+
"has_attachment": bool(message.get("hasAttachments") or attachment_items),
|
|
90
|
+
"attachments": attachment_items,
|
|
91
|
+
"is_read": message.get("isRead"),
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def normalize_send_payload(payload: OutlookSendMessagePayload) -> Dict[str, Any]:
|
|
97
|
+
if isinstance(payload, OutlookSendMessageInput):
|
|
98
|
+
return payload.to_graph_message()
|
|
99
|
+
if isinstance(payload, dict):
|
|
100
|
+
if "message" in payload and isinstance(payload["message"], dict):
|
|
101
|
+
return dict(payload["message"])
|
|
102
|
+
return dict(payload)
|
|
103
|
+
raise TypeError(f"Unsupported Outlook dispatch payload type: {type(payload)}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_ack_patch(config: OutlookAckActionConfig) -> Dict[str, Any]:
|
|
107
|
+
return {"isRead": True} if config.mark_as_read else {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_nack_patch(config: OutlookNackActionConfig) -> Dict[str, Any]:
|
|
111
|
+
patch: Dict[str, Any] = {}
|
|
112
|
+
if config.mark_as_unread:
|
|
113
|
+
patch["isRead"] = False
|
|
114
|
+
if config.categories:
|
|
115
|
+
patch["categories"] = list(
|
|
116
|
+
dict.fromkeys(category.strip() for category in config.categories if category.strip())
|
|
117
|
+
)
|
|
118
|
+
return patch
|
|
@@ -55,13 +55,14 @@ _DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
|
55
55
|
aio_pika.exceptions.ChannelClosed,
|
|
56
56
|
)
|
|
57
57
|
|
|
58
|
-
# Same as above plus ``TimeoutError`` (raised by the async timeout
|
|
59
|
-
# ack/nack stalls on a half-open socket). A stalled ack is
|
|
60
|
-
# channel: we tear down and let the broker redeliver instead
|
|
61
|
-
# the heartbeat eventually fires.
|
|
58
|
+
# Same as above plus ``asyncio.TimeoutError`` (raised by the async timeout
|
|
59
|
+
# context when an ack/nack stalls on a half-open socket). A stalled ack is
|
|
60
|
+
# functionally a dead channel: we tear down and let the broker redeliver instead
|
|
61
|
+
# of blocking until the heartbeat eventually fires. Use asyncio's name
|
|
62
|
+
# explicitly because it is distinct from builtins.TimeoutError on Python 3.10.
|
|
62
63
|
_DEAD_CHANNEL_TIMEOUT_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
63
64
|
*_DEAD_CHANNEL_EXCEPTIONS,
|
|
64
|
-
TimeoutError,
|
|
65
|
+
asyncio.TimeoutError,
|
|
65
66
|
)
|
|
66
67
|
|
|
67
68
|
|
|
@@ -107,6 +108,54 @@ class AsyncRabbitMQService:
|
|
|
107
108
|
|
|
108
109
|
# ---------- Connection / Channel ----------
|
|
109
110
|
|
|
111
|
+
async def _open_owned_robust_connection(
|
|
112
|
+
self,
|
|
113
|
+
url: str,
|
|
114
|
+
kwargs: Dict[str, Any],
|
|
115
|
+
) -> AbstractRobustConnection:
|
|
116
|
+
"""Open a robust connection without losing ownership on startup failure.
|
|
117
|
+
|
|
118
|
+
``aio_pika.connect_robust`` constructs ``RobustConnection`` and starts
|
|
119
|
+
its background reconnection task before awaiting the initial broker
|
|
120
|
+
connection. If that await times out or is cancelled, the helper never
|
|
121
|
+
returns the connection object, so callers cannot close the reconnect
|
|
122
|
+
task and ``asyncio.run`` can hang while shutting it down.
|
|
123
|
+
|
|
124
|
+
aio-pika exposes ``connection_class`` as its connection factory. Capture
|
|
125
|
+
the object synchronously at that boundary, before its first await, and
|
|
126
|
+
close it on every unsuccessful exit from ``connect_robust``.
|
|
127
|
+
"""
|
|
128
|
+
pending_connection: Optional[AbstractRobustConnection] = None
|
|
129
|
+
|
|
130
|
+
def capture_connection(*args: Any, **connection_kwargs: Any) -> AbstractRobustConnection:
|
|
131
|
+
nonlocal pending_connection
|
|
132
|
+
pending_connection = aio_pika.RobustConnection(*args, **connection_kwargs)
|
|
133
|
+
return pending_connection
|
|
134
|
+
|
|
135
|
+
connection_factory = cast(type[AbstractRobustConnection], capture_connection)
|
|
136
|
+
try:
|
|
137
|
+
async with timeout_after(self.client.connect_timeout):
|
|
138
|
+
return await aio_pika.connect_robust( # type: ignore[call-overload]
|
|
139
|
+
url,
|
|
140
|
+
heartbeat=self.client.heartbeat,
|
|
141
|
+
timeout=self.client.connect_timeout,
|
|
142
|
+
connection_class=connection_factory,
|
|
143
|
+
**kwargs,
|
|
144
|
+
)
|
|
145
|
+
except BaseException as exc:
|
|
146
|
+
connection_to_close = pending_connection
|
|
147
|
+
pending_connection = None
|
|
148
|
+
try:
|
|
149
|
+
await self._close_connection_safely(
|
|
150
|
+
connection_to_close,
|
|
151
|
+
f"initial connection attempt failed: {type(exc).__name__}",
|
|
152
|
+
)
|
|
153
|
+
finally:
|
|
154
|
+
# Do not retain the partial connection in this exception's
|
|
155
|
+
# traceback after its reconnect task has been stopped.
|
|
156
|
+
del connection_to_close
|
|
157
|
+
raise
|
|
158
|
+
|
|
110
159
|
async def _get_connection(self) -> AbstractRobustConnection:
|
|
111
160
|
async with self._connection_lock:
|
|
112
161
|
if self._connection is not None and not self._connection.is_closed:
|
|
@@ -116,7 +165,7 @@ class AsyncRabbitMQService:
|
|
|
116
165
|
try:
|
|
117
166
|
async with timeout_after(self.client.connect_timeout):
|
|
118
167
|
await connected.wait()
|
|
119
|
-
except TimeoutError:
|
|
168
|
+
except asyncio.TimeoutError:
|
|
120
169
|
self._consumer_state = "connect_stalled"
|
|
121
170
|
await self._reset_connection("robust reconnect timed out")
|
|
122
171
|
raise
|
|
@@ -133,14 +182,8 @@ class AsyncRabbitMQService:
|
|
|
133
182
|
|
|
134
183
|
self._consumer_state = "connecting"
|
|
135
184
|
try:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
url,
|
|
139
|
-
heartbeat=self.client.heartbeat,
|
|
140
|
-
timeout=self.client.connect_timeout,
|
|
141
|
-
**kwargs,
|
|
142
|
-
)
|
|
143
|
-
except TimeoutError:
|
|
185
|
+
connection = await self._open_owned_robust_connection(url, kwargs)
|
|
186
|
+
except asyncio.TimeoutError:
|
|
144
187
|
self._consumer_state = "connect_stalled"
|
|
145
188
|
raise
|
|
146
189
|
except BaseException:
|
|
@@ -181,7 +224,7 @@ class AsyncRabbitMQService:
|
|
|
181
224
|
try:
|
|
182
225
|
async with timeout_after(close_timeout):
|
|
183
226
|
await connection.close()
|
|
184
|
-
except TimeoutError:
|
|
227
|
+
except asyncio.TimeoutError:
|
|
185
228
|
logger.error(
|
|
186
229
|
"Timed out closing RabbitMQ connection after %.1fs (%s)",
|
|
187
230
|
close_timeout,
|
|
@@ -293,7 +336,7 @@ class AsyncRabbitMQService:
|
|
|
293
336
|
try:
|
|
294
337
|
async with timeout_after(self.client.channel_timeout):
|
|
295
338
|
self._consume_channel = await connection.channel()
|
|
296
|
-
except TimeoutError:
|
|
339
|
+
except asyncio.TimeoutError:
|
|
297
340
|
self._consumer_state = "connect_stalled"
|
|
298
341
|
await self._reset_connection("consume channel creation timed out")
|
|
299
342
|
raise
|
|
@@ -328,7 +371,7 @@ class AsyncRabbitMQService:
|
|
|
328
371
|
try:
|
|
329
372
|
async with timeout_after(self.client.channel_timeout):
|
|
330
373
|
self._publish_channel = await connection.channel()
|
|
331
|
-
except TimeoutError:
|
|
374
|
+
except asyncio.TimeoutError:
|
|
332
375
|
await self._reset_connection("publish channel creation timed out")
|
|
333
376
|
raise
|
|
334
377
|
|
|
@@ -455,7 +498,7 @@ class AsyncRabbitMQService:
|
|
|
455
498
|
break
|
|
456
499
|
if epoch == self._consumer_epoch and delivery is not None:
|
|
457
500
|
buffer.append(delivery)
|
|
458
|
-
except TimeoutError:
|
|
501
|
+
except asyncio.TimeoutError:
|
|
459
502
|
poll_completed = True
|
|
460
503
|
else:
|
|
461
504
|
poll_completed = True
|