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.
@@ -0,0 +1,198 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ..connector import Connector
6
+ from ..transaction import Transaction
7
+ from .models import (
8
+ OutlookAckActionConfig,
9
+ OutlookConsumerConfig,
10
+ OutlookEmailAddress,
11
+ OutlookFlagStatus,
12
+ OutlookGraphClient,
13
+ OutlookMessageQuery,
14
+ OutlookNackActionConfig,
15
+ OutlookProducerConfig,
16
+ OutlookSendMessagePayload,
17
+ OutlookWellKnownFolder,
18
+ )
19
+ from .service import OutlookGraphService
20
+ from .utils import build_ack_patch, build_nack_patch, merge_query
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class OutlookGraphConnector(Connector):
26
+ service: OutlookGraphService
27
+
28
+ def __init__(
29
+ self,
30
+ client: OutlookGraphClient,
31
+ consumer_config: Optional[OutlookConsumerConfig] = None,
32
+ producer_config: Optional[OutlookProducerConfig] = None,
33
+ ) -> None:
34
+ self.service = OutlookGraphService(client)
35
+ self._consumer_config = consumer_config
36
+ self._producer_config = producer_config or OutlookProducerConfig()
37
+
38
+ def fetch_transactions(
39
+ self,
40
+ batch_size: Optional[int] = None,
41
+ query_overrides: Optional[OutlookMessageQuery] = None,
42
+ *args: Any,
43
+ **kwargs: Any,
44
+ ) -> List[Transaction]:
45
+ if self._consumer_config is None:
46
+ raise ValueError("OutlookGraphConnector requires a consumer_config to fetch transactions")
47
+ config = self._consumer_config
48
+ return self.service.fetch_items(
49
+ merge_query(config, query_overrides, **kwargs),
50
+ batch_size or config.batch_size,
51
+ download_attachments=config.download_attachments,
52
+ )
53
+
54
+ def dispatch_transactions(
55
+ self,
56
+ transactions: List[Transaction],
57
+ *args: Any,
58
+ **kwargs: Any,
59
+ ) -> List[str]:
60
+ dispatched_ids: List[str] = []
61
+ for transaction in transactions:
62
+ self.service.send_message(
63
+ transaction.payload,
64
+ producer_config=self._producer_config,
65
+ )
66
+ dispatched_ids.append(transaction.id)
67
+ return dispatched_ids
68
+
69
+ def send_message(self, payload: OutlookSendMessagePayload) -> None:
70
+ self.service.send_message(payload, producer_config=self._producer_config)
71
+
72
+ def mark_as_read(self, message_id: str) -> Dict[str, Any]:
73
+ return self.service.mark_as_read(message_id)
74
+
75
+ def mark_as_unread(self, message_id: str) -> Dict[str, Any]:
76
+ return self.service.mark_as_unread(message_id)
77
+
78
+ def set_flag(
79
+ self,
80
+ message_id: str,
81
+ status: OutlookFlagStatus | str = OutlookFlagStatus.FLAGGED,
82
+ ) -> Dict[str, Any]:
83
+ return self.service.set_flag(message_id, status)
84
+
85
+ def set_categories(self, message_id: str, categories: List[str]) -> Dict[str, Any]:
86
+ return self.service.set_categories(message_id, categories)
87
+
88
+ def move_message(
89
+ self,
90
+ message_id: str,
91
+ folder_id: OutlookWellKnownFolder | str,
92
+ ) -> Dict[str, Any]:
93
+ return self.service.move_message(message_id, folder_id)
94
+
95
+ def delete_message(self, message_id: str) -> None:
96
+ self.service.delete_message(message_id)
97
+
98
+ def reply(self, message_id: str, *, comment: str = "") -> None:
99
+ self.service.reply(message_id, comment=comment)
100
+
101
+ def reply_all(self, message_id: str, *, comment: str = "") -> None:
102
+ self.service.reply_all(message_id, comment=comment)
103
+
104
+ def forward(
105
+ self,
106
+ message_id: str,
107
+ to: List[OutlookEmailAddress],
108
+ comment: str = "",
109
+ ) -> None:
110
+ self.service.forward(message_id, to, comment=comment)
111
+
112
+ def list_attachments(
113
+ self,
114
+ message_id: str,
115
+ download_content: bool = False,
116
+ ) -> List[Dict[str, Any]]:
117
+ return self.service.list_attachments(message_id, download_content=download_content)
118
+
119
+ def save_attachment(
120
+ self,
121
+ attachment: Dict[str, Any],
122
+ destination: str | Path,
123
+ overwrite: bool = False,
124
+ ) -> Path:
125
+ return self.service.save_attachment(attachment, destination, overwrite=overwrite)
126
+
127
+ def list_mail_folders(self, *, include_hidden: bool = False) -> List[Dict[str, Any]]:
128
+ return self.service.list_mail_folders(include_hidden=include_hidden)
129
+
130
+ def get_mail_folder(self, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
131
+ return self.service.get_mail_folder(folder_id)
132
+
133
+ def reset_pagination(self) -> None:
134
+ self.service.reset_pagination()
135
+
136
+ def fetch_transaction_by_id(
137
+ self,
138
+ transaction_id: str,
139
+ *args: Any,
140
+ **kwargs: Any,
141
+ ) -> Transaction:
142
+ config = self._consumer_config
143
+ return self.service.find_message_transaction(
144
+ transaction_id,
145
+ select=kwargs.get("select"),
146
+ download_attachments=config.download_attachments if config else False,
147
+ )
148
+
149
+ def get_transactions_count(self, *args: Any, **kwargs: Any) -> int:
150
+ if self._consumer_config is None:
151
+ raise ValueError("OutlookGraphConnector requires a consumer_config to count transactions")
152
+ query_overrides = kwargs.pop("query_overrides", None)
153
+ max_pages = kwargs.pop("max_pages", None)
154
+ query = merge_query(self._consumer_config, query_overrides, **kwargs)
155
+ return self.service.get_messages_count(query, max_pages=max_pages)
156
+
157
+ def ack_transaction(
158
+ self,
159
+ transaction: Transaction,
160
+ ack_config: Optional[OutlookAckActionConfig] = None,
161
+ ) -> None:
162
+ config = ack_config or (self._consumer_config.ack_config if self._consumer_config else None)
163
+ if config is None:
164
+ return
165
+ if config.delete:
166
+ self.service.delete_message(transaction.id)
167
+ return
168
+ patch = build_ack_patch(config)
169
+ if patch:
170
+ self.service.update_message(transaction.id, patch)
171
+ if config.move_to_folder_id:
172
+ self.service.move_message(transaction.id, config.move_to_folder_id)
173
+
174
+ def nack_transaction(
175
+ self,
176
+ transaction: Transaction,
177
+ requeue: bool = True,
178
+ nack_config: Optional[OutlookNackActionConfig] = None,
179
+ ) -> None:
180
+ config = nack_config or (self._consumer_config.nack_config if self._consumer_config else None)
181
+ if config is None:
182
+ if not requeue:
183
+ raise ValueError("nack_config with a failure category or folder is required when requeue=False")
184
+ config = OutlookNackActionConfig()
185
+ if not requeue and not config.move_to_folder_id and not config.categories:
186
+ raise ValueError("nack_config with a failure category or folder is required when requeue=False")
187
+
188
+ patch = build_nack_patch(config)
189
+ if patch:
190
+ self.service.update_message(transaction.id, patch)
191
+ if config.move_to_folder_id:
192
+ self.service.move_message(transaction.id, config.move_to_folder_id)
193
+ if requeue:
194
+ self.service.reset_pagination()
195
+ logger.debug("Outlook message %s was made available for refetch", transaction.id)
196
+
197
+ def close(self) -> None:
198
+ self.service.close()
@@ -0,0 +1,315 @@
1
+ import base64
2
+ import mimetypes
3
+ from datetime import datetime, timezone
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar, Dict, List, Literal, Optional, Union
7
+
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+
10
+
11
+ class OutlookWellKnownFolder(str, Enum):
12
+ INBOX = "inbox"
13
+ SENT_ITEMS = "sentitems"
14
+ DELETED_ITEMS = "deleteditems"
15
+
16
+
17
+ class OutlookFlagStatus(str, Enum):
18
+ NOT_FLAGGED = "notFlagged"
19
+ COMPLETE = "complete"
20
+ FLAGGED = "flagged"
21
+
22
+
23
+ class OutlookMessageSearch(BaseModel):
24
+ """Discoverable Microsoft Graph message search fields."""
25
+
26
+ text: Optional[str] = None
27
+ subject: Optional[str] = None
28
+ sender: Optional[str] = None
29
+ recipient: Optional[str] = None
30
+
31
+ def to_graph(self) -> str:
32
+ terms = []
33
+ if self.text and self.text.strip():
34
+ terms.append(self.text.strip())
35
+ for field, value in (
36
+ ("subject", self.subject),
37
+ ("from", self.sender),
38
+ ("to", self.recipient),
39
+ ):
40
+ if value and value.strip():
41
+ terms.append(f"{field}:{self._escape(value)}")
42
+ return " AND ".join(terms)
43
+
44
+ @staticmethod
45
+ def _escape(value: str) -> str:
46
+ """Escape a string for use in an OData filter."""
47
+ escaped = value.strip().replace("\\", "\\\\").replace('"', '\\"')
48
+ return f'\\"{escaped}\\"' if any(character.isspace() for character in escaped) else escaped
49
+
50
+
51
+ class OutlookMessageFilter(BaseModel):
52
+ """Common message filters without requiring OData syntax."""
53
+
54
+ unread_only: bool = False
55
+ has_attachments: Optional[bool] = None
56
+ sender: Optional[str] = None
57
+ subject_starts_with: Optional[str] = None
58
+ received_after: Optional[datetime] = None
59
+ received_before: Optional[datetime] = None
60
+
61
+ def to_graph(self) -> List[str]:
62
+ """Convert the filter to a list of OData filters."""
63
+ filters: List[str] = []
64
+ if self.unread_only:
65
+ filters.append("isRead eq false")
66
+ if self.has_attachments is not None:
67
+ filters.append(f"hasAttachments eq {str(self.has_attachments).lower()}")
68
+ if self.sender and self.sender.strip():
69
+ filters.append(f"from/emailAddress/address eq {self._odata_string(self.sender)}")
70
+ if self.subject_starts_with and self.subject_starts_with.strip():
71
+ value = self._odata_string(self.subject_starts_with)
72
+ filters.append(f"startswith(subject,{value})")
73
+ if self.received_after is not None:
74
+ filters.append(f"receivedDateTime ge {self._format_datetime(self.received_after)}")
75
+ if self.received_before is not None:
76
+ filters.append(f"receivedDateTime le {self._format_datetime(self.received_before)}")
77
+ return filters
78
+
79
+ @staticmethod
80
+ def _odata_string(value: str) -> str:
81
+ """Escape a string for use in an OData filter."""
82
+ return "'" + value.strip().replace("'", "''") + "'"
83
+
84
+ @staticmethod
85
+ def _format_datetime(value: datetime) -> str:
86
+ """Format a datetime for use in an OData filter."""
87
+ if value.tzinfo is None:
88
+ value = value.replace(tzinfo=timezone.utc)
89
+ return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
90
+
91
+
92
+ class OutlookGraphClient(BaseModel):
93
+ """Microsoft Entra application credentials and target mailbox."""
94
+
95
+ tenant_id: str
96
+ client_id: str
97
+ client_secret: str
98
+ user_email: str
99
+ scopes: List[str] = Field(default_factory=lambda: ["https://graph.microsoft.com/.default"])
100
+ graph_base_url: str = "https://graph.microsoft.com/v1.0"
101
+ authority_base_url: str = "https://login.microsoftonline.com"
102
+ timeout_sec: int = Field(default=30, ge=1)
103
+
104
+ @property
105
+ def authority(self) -> str:
106
+ return f"{self.authority_base_url.rstrip('/')}/{self.tenant_id}"
107
+
108
+
109
+ class OutlookMessageQuery(BaseModel):
110
+ """Supported Microsoft Graph query options for a mailbox folder."""
111
+
112
+ DEFAULT_MESSAGE_SELECT: ClassVar[List[str]] = [
113
+ "id",
114
+ "subject",
115
+ "receivedDateTime",
116
+ "hasAttachments",
117
+ "from",
118
+ "toRecipients",
119
+ "ccRecipients",
120
+ "bccRecipients",
121
+ "isRead",
122
+ "importance",
123
+ "bodyPreview",
124
+ "body",
125
+ "conversationId",
126
+ "internetMessageId",
127
+ "parentFolderId",
128
+ "categories",
129
+ ]
130
+ _SEARCH_AND_FILTER_ERROR: ClassVar[str] = "Cannot combine search and filter in the same query."
131
+
132
+ folder_id: str = "Inbox"
133
+ search: Optional[Union[OutlookMessageSearch, str]] = None
134
+ filter: Optional[Union[OutlookMessageFilter, str]] = None
135
+ select: List[str] = Field(default_factory=lambda: list(OutlookMessageQuery.DEFAULT_MESSAGE_SELECT))
136
+ order_by: Optional[str] = "receivedDateTime asc"
137
+
138
+ @field_validator("folder_id", mode="before")
139
+ @classmethod
140
+ def normalize_folder_id(cls, value: Any) -> str:
141
+ if isinstance(value, OutlookWellKnownFolder):
142
+ return value.value
143
+ if isinstance(value, str) and value.strip():
144
+ return value.strip()
145
+ raise ValueError("folder_id is required")
146
+
147
+ def search_expression(self) -> Optional[str]:
148
+ if self.search is None:
149
+ return None
150
+ value = self.search.to_graph() if isinstance(self.search, OutlookMessageSearch) else self.search.strip()
151
+ return value or None
152
+
153
+ def filter_expressions(self) -> List[str]:
154
+ if self.filter is None:
155
+ return []
156
+ if isinstance(self.filter, OutlookMessageFilter):
157
+ return self.filter.to_graph()
158
+ value = self.filter.strip()
159
+ return [value] if value else []
160
+
161
+ @model_validator(mode="after")
162
+ def reject_search_with_filter(self) -> "OutlookMessageQuery":
163
+ if self.search_expression() and self.filter_expressions():
164
+ raise ValueError(self._SEARCH_AND_FILTER_ERROR)
165
+ return self
166
+
167
+ def to_query_params(self, *, top: Optional[int] = None) -> Dict[str, Any]:
168
+ params: Dict[str, Any] = {}
169
+ if top is not None:
170
+ params["$top"] = top
171
+ search = self.search_expression()
172
+ if search:
173
+ params["$search"] = search if search.startswith('"') and search.endswith('"') else f'"{search}"'
174
+ filters = self.filter_expressions()
175
+ order_by = self.order_by
176
+ if filters and order_by:
177
+ order_field = order_by.split(maxsplit=1)[0]
178
+ if order_field.casefold() != "receiveddatetime":
179
+ raise ValueError("Filtered Outlook queries only support ordering by receivedDateTime")
180
+ filters.insert(0, "receivedDateTime ge 1900-01-01T00:00:00Z")
181
+ if filters:
182
+ params["$filter"] = " and ".join(filters)
183
+ if self.select:
184
+ params["$select"] = ",".join(self.select)
185
+ if order_by and not search:
186
+ params["$orderby"] = order_by
187
+ return params
188
+
189
+ @property
190
+ def requires_eventual_consistency(self) -> bool:
191
+ return bool(self.search_expression())
192
+
193
+
194
+ class OutlookAckActionConfig(BaseModel):
195
+ """Actions applied after a message is processed successfully."""
196
+
197
+ mark_as_read: bool = True
198
+ move_to_folder_id: Optional[str] = None
199
+ delete: bool = False
200
+
201
+ @model_validator(mode="after")
202
+ def normalize_delete_action(self) -> "OutlookAckActionConfig":
203
+ if self.delete and self.move_to_folder_id:
204
+ raise ValueError("Ack cannot move and delete the same message")
205
+ if self.delete:
206
+ self.mark_as_read = False
207
+ return self
208
+
209
+
210
+ class OutlookNackActionConfig(BaseModel):
211
+ """Actions applied after message processing fails."""
212
+
213
+ mark_as_unread: bool = True
214
+ move_to_folder_id: Optional[str] = None
215
+ categories: List[str] = Field(default_factory=list)
216
+
217
+
218
+ class OutlookConsumerConfig(OutlookMessageQuery):
219
+ """Configuration for consuming Outlook messages."""
220
+
221
+ batch_size: int = Field(default=10, ge=1, le=1000)
222
+ download_attachments: bool = False
223
+ ack_config: Optional[OutlookAckActionConfig] = None
224
+ nack_config: Optional[OutlookNackActionConfig] = None
225
+
226
+
227
+ class OutlookProducerConfig(BaseModel):
228
+ """Configuration for producing Outlook messages."""
229
+
230
+ save_to_sent_items: bool = True
231
+
232
+
233
+ class OutlookEmailAddress(BaseModel):
234
+ email: str
235
+ name: Optional[str] = None
236
+
237
+ def to_graph(self) -> Dict[str, Dict[str, str]]:
238
+ value = {"address": self.email}
239
+ if self.name:
240
+ value["name"] = self.name
241
+ return {"emailAddress": value}
242
+
243
+
244
+ class OutlookAttachmentInput(BaseModel):
245
+ """Input for an Outlook attachment."""
246
+
247
+ filename: Optional[str] = None
248
+ content_type: Optional[str] = None
249
+ content: Optional[bytes] = None
250
+ content_base64: Optional[str] = None
251
+ file_path: Optional[Path] = None
252
+
253
+ def to_graph(self) -> Dict[str, Any]:
254
+ filename = self.filename
255
+ content_type = self.content_type
256
+ raw = self.content
257
+
258
+ if self.file_path is not None:
259
+ path = Path(self.file_path)
260
+ if not path.is_file():
261
+ raise FileNotFoundError(f"Outlook attachment not found: {path}")
262
+ filename = filename or path.name
263
+ content_type = content_type or mimetypes.guess_type(path.name)[0]
264
+ raw = path.read_bytes()
265
+
266
+ if self.content_base64 is not None:
267
+ encoded = self.content_base64
268
+ elif raw is not None:
269
+ encoded = base64.b64encode(raw).decode("ascii")
270
+ else:
271
+ raise ValueError("Outlook attachment requires content, content_base64, or file_path")
272
+
273
+ if not filename:
274
+ raise ValueError("Outlook attachment requires a filename")
275
+
276
+ return {
277
+ "@odata.type": "#microsoft.graph.fileAttachment",
278
+ "name": filename,
279
+ "contentType": content_type or "application/octet-stream",
280
+ "contentBytes": encoded,
281
+ }
282
+
283
+
284
+ class OutlookSendMessageInput(BaseModel):
285
+ """Input for sending an Outlook message."""
286
+
287
+ to: List[OutlookEmailAddress]
288
+ subject: str
289
+ body: str
290
+ body_type: Literal["HTML", "Text"] = "HTML"
291
+ cc: List[OutlookEmailAddress] = Field(default_factory=list)
292
+ bcc: List[OutlookEmailAddress] = Field(default_factory=list)
293
+ reply_to: List[OutlookEmailAddress] = Field(default_factory=list)
294
+ attachments: List[OutlookAttachmentInput] = Field(default_factory=list)
295
+
296
+ def to_graph_message(self) -> Dict[str, Any]:
297
+ message: Dict[str, Any] = {
298
+ "subject": self.subject,
299
+ "body": {"contentType": self.body_type, "content": self.body},
300
+ "toRecipients": [address.to_graph() for address in self.to],
301
+ }
302
+ optional_recipients = {
303
+ "ccRecipients": self.cc,
304
+ "bccRecipients": self.bcc,
305
+ "replyTo": self.reply_to,
306
+ }
307
+ for key, values in optional_recipients.items():
308
+ if values:
309
+ message[key] = [address.to_graph() for address in values]
310
+ if self.attachments:
311
+ message["attachments"] = [attachment.to_graph() for attachment in self.attachments]
312
+ return message
313
+
314
+
315
+ OutlookSendMessagePayload = Union[OutlookSendMessageInput, Dict[str, Any]]