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,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]]
|