ergon-framework-python 0.2.3__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/task/mixins/consumer.py +33 -21
- {ergon_framework_python-0.2.3.dist-info → ergon_framework_python-0.2.4.dist-info}/METADATA +47 -3
- {ergon_framework_python-0.2.3.dist-info → ergon_framework_python-0.2.4.dist-info}/RECORD +16 -9
- {ergon_framework_python-0.2.3.dist-info → ergon_framework_python-0.2.4.dist-info}/WHEEL +0 -0
- {ergon_framework_python-0.2.3.dist-info → ergon_framework_python-0.2.4.dist-info}/entry_points.txt +0 -0
- {ergon_framework_python-0.2.3.dist-info → ergon_framework_python-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {ergon_framework_python-0.2.3.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
|
ergon/task/mixins/consumer.py
CHANGED
|
@@ -103,7 +103,7 @@ class ConsumerMixin(ABC):
|
|
|
103
103
|
# -----------------------
|
|
104
104
|
# 1) PROCESS STEP
|
|
105
105
|
# -----------------------
|
|
106
|
-
logger.
|
|
106
|
+
logger.debug("Transaction %s processing started", transaction.id)
|
|
107
107
|
process_ok, process_result = self._handle_process(transaction, policy.process.retry)
|
|
108
108
|
|
|
109
109
|
# -----------------------
|
|
@@ -195,7 +195,7 @@ class ConsumerMixin(ABC):
|
|
|
195
195
|
# PROCESS HANDLER
|
|
196
196
|
# =====================================================================
|
|
197
197
|
def _handle_process(self, transaction, retry: policies.RetryPolicy):
|
|
198
|
-
logger.
|
|
198
|
+
logger.debug("Transaction %s process handler started", transaction.id)
|
|
199
199
|
stage_start = time.perf_counter()
|
|
200
200
|
success, result = helpers.run_fn(
|
|
201
201
|
fn=lambda: self.process_transaction(transaction),
|
|
@@ -210,8 +210,10 @@ class ConsumerMixin(ABC):
|
|
|
210
210
|
duration=time.perf_counter() - stage_start,
|
|
211
211
|
outcome="ok" if success else "error",
|
|
212
212
|
)
|
|
213
|
-
logger.
|
|
214
|
-
|
|
213
|
+
logger.debug(
|
|
214
|
+
"Transaction %s process handler completed with status: %s",
|
|
215
|
+
transaction.id,
|
|
216
|
+
"success" if success else "error",
|
|
215
217
|
)
|
|
216
218
|
return success, result
|
|
217
219
|
|
|
@@ -219,7 +221,7 @@ class ConsumerMixin(ABC):
|
|
|
219
221
|
# SUCCESS HANDLER
|
|
220
222
|
# =====================================================================
|
|
221
223
|
def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
|
|
222
|
-
logger.
|
|
224
|
+
logger.debug("Transaction %s success handler started", transaction.id)
|
|
223
225
|
stage_start = time.perf_counter()
|
|
224
226
|
success, handler_result = helpers.run_fn(
|
|
225
227
|
fn=lambda: self.handle_process_success(transaction, result),
|
|
@@ -234,8 +236,10 @@ class ConsumerMixin(ABC):
|
|
|
234
236
|
duration=time.perf_counter() - stage_start,
|
|
235
237
|
outcome="ok" if success else "error",
|
|
236
238
|
)
|
|
237
|
-
logger.
|
|
238
|
-
|
|
239
|
+
logger.debug(
|
|
240
|
+
"Transaction %s success handler completed with status: %s",
|
|
241
|
+
transaction.id,
|
|
242
|
+
"success" if success else "error",
|
|
239
243
|
)
|
|
240
244
|
return success, handler_result
|
|
241
245
|
|
|
@@ -243,7 +247,7 @@ class ConsumerMixin(ABC):
|
|
|
243
247
|
# EXCEPTION HANDLER
|
|
244
248
|
# =====================================================================
|
|
245
249
|
def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
|
|
246
|
-
logger.
|
|
250
|
+
logger.debug("Transaction %s exception handler started", transaction.id)
|
|
247
251
|
stage_start = time.perf_counter()
|
|
248
252
|
success, result = helpers.run_fn(
|
|
249
253
|
fn=lambda: self.handle_process_exception(transaction, exc),
|
|
@@ -258,8 +262,10 @@ class ConsumerMixin(ABC):
|
|
|
258
262
|
duration=time.perf_counter() - stage_start,
|
|
259
263
|
outcome="ok" if success else "error",
|
|
260
264
|
)
|
|
261
|
-
logger.
|
|
262
|
-
|
|
265
|
+
logger.debug(
|
|
266
|
+
"Transaction %s exception handler completed with status: %s",
|
|
267
|
+
transaction.id,
|
|
268
|
+
"success" if success else "error",
|
|
263
269
|
)
|
|
264
270
|
return success, result
|
|
265
271
|
|
|
@@ -337,7 +343,7 @@ class ConsumerMixin(ABC):
|
|
|
337
343
|
# -------------------------
|
|
338
344
|
# FETCH
|
|
339
345
|
# -------------------------
|
|
340
|
-
logger.
|
|
346
|
+
logger.debug(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
|
|
341
347
|
success, result = self._handle_fetch(conn, policy.fetch)
|
|
342
348
|
if not success:
|
|
343
349
|
logger.error(f"Fetch failed → {result}")
|
|
@@ -645,7 +651,7 @@ class AsyncConsumerMixin(ABC):
|
|
|
645
651
|
# -----------------------
|
|
646
652
|
# 1) PROCESS STEP
|
|
647
653
|
# -----------------------
|
|
648
|
-
logger.
|
|
654
|
+
logger.debug("Transaction %s processing started", transaction.id)
|
|
649
655
|
process_ok, process_result = await self._handle_process(transaction, policy.process.retry)
|
|
650
656
|
|
|
651
657
|
# -----------------------
|
|
@@ -735,7 +741,7 @@ class AsyncConsumerMixin(ABC):
|
|
|
735
741
|
# PROCESS HANDLER WITH RETRIES
|
|
736
742
|
# =====================================================================
|
|
737
743
|
async def _handle_process(self, transaction, retry: policies.RetryPolicy):
|
|
738
|
-
logger.
|
|
744
|
+
logger.debug("Transaction %s process handler started", transaction.id)
|
|
739
745
|
stage_start = time.perf_counter()
|
|
740
746
|
success, result = await helpers.run_fn_async(
|
|
741
747
|
fn=lambda: self.process_transaction(transaction),
|
|
@@ -750,8 +756,10 @@ class AsyncConsumerMixin(ABC):
|
|
|
750
756
|
duration=time.perf_counter() - stage_start,
|
|
751
757
|
outcome="ok" if success else "error",
|
|
752
758
|
)
|
|
753
|
-
logger.
|
|
754
|
-
|
|
759
|
+
logger.debug(
|
|
760
|
+
"Transaction %s process handler completed with status: %s",
|
|
761
|
+
transaction.id,
|
|
762
|
+
"success" if success else "error",
|
|
755
763
|
)
|
|
756
764
|
return success, result
|
|
757
765
|
|
|
@@ -759,7 +767,7 @@ class AsyncConsumerMixin(ABC):
|
|
|
759
767
|
# SUCCESS HANDLER
|
|
760
768
|
# =====================================================================
|
|
761
769
|
async def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
|
|
762
|
-
logger.
|
|
770
|
+
logger.debug("Transaction %s success handler started", transaction.id)
|
|
763
771
|
stage_start = time.perf_counter()
|
|
764
772
|
success, handler_result = await helpers.run_fn_async(
|
|
765
773
|
fn=lambda: self.handle_process_success(transaction, result),
|
|
@@ -774,8 +782,10 @@ class AsyncConsumerMixin(ABC):
|
|
|
774
782
|
duration=time.perf_counter() - stage_start,
|
|
775
783
|
outcome="ok" if success else "error",
|
|
776
784
|
)
|
|
777
|
-
logger.
|
|
778
|
-
|
|
785
|
+
logger.debug(
|
|
786
|
+
"Transaction %s success handler completed with status: %s",
|
|
787
|
+
transaction.id,
|
|
788
|
+
"success" if success else "error",
|
|
779
789
|
)
|
|
780
790
|
return success, handler_result
|
|
781
791
|
|
|
@@ -783,7 +793,7 @@ class AsyncConsumerMixin(ABC):
|
|
|
783
793
|
# EXCEPTION HANDLER
|
|
784
794
|
# =====================================================================
|
|
785
795
|
async def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
|
|
786
|
-
logger.
|
|
796
|
+
logger.debug("Transaction %s exception handler started", transaction.id)
|
|
787
797
|
stage_start = time.perf_counter()
|
|
788
798
|
success, result = await helpers.run_fn_async(
|
|
789
799
|
fn=lambda: self.handle_process_exception(transaction, exc),
|
|
@@ -798,8 +808,10 @@ class AsyncConsumerMixin(ABC):
|
|
|
798
808
|
duration=time.perf_counter() - stage_start,
|
|
799
809
|
outcome="ok" if success else "error",
|
|
800
810
|
)
|
|
801
|
-
logger.
|
|
802
|
-
|
|
811
|
+
logger.debug(
|
|
812
|
+
"Transaction %s exception handler completed with status: %s",
|
|
813
|
+
transaction.id,
|
|
814
|
+
"success" if success else "error",
|
|
803
815
|
)
|
|
804
816
|
return success, result
|
|
805
817
|
|