ergon-framework-python 0.2.0__py3-none-any.whl → 0.2.2__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 +20 -0
- ergon/connector/ergon_platform/__init__.py +78 -11
- ergon/connector/ergon_platform/async_connector.py +3 -221
- ergon/connector/ergon_platform/channels/__init__.py +54 -0
- ergon/connector/ergon_platform/channels/adapters.py +230 -0
- ergon/connector/ergon_platform/channels/async_connector.py +327 -0
- ergon/connector/ergon_platform/channels/connector.py +294 -0
- ergon/connector/ergon_platform/channels/models.py +460 -0
- ergon/connector/ergon_platform/channels/services/__init__.py +39 -0
- ergon/connector/ergon_platform/channels/services/activity.py +300 -0
- ergon/connector/ergon_platform/channels/services/addresses.py +325 -0
- ergon/connector/ergon_platform/channels/services/attachments.py +283 -0
- ergon/connector/ergon_platform/channels/services/messages.py +127 -0
- ergon/connector/ergon_platform/channels/services/records.py +75 -0
- ergon/connector/ergon_platform/channels/services/service.py +34 -0
- ergon/connector/ergon_platform/connector.py +3 -198
- ergon/connector/ergon_platform/models.py +1 -73
- ergon/connector/ergon_platform/utils.py +39 -248
- ergon/connector/ergon_platform/workflows/__init__.py +52 -0
- ergon/connector/ergon_platform/{_operations.py → workflows/_operations.py} +8 -3
- ergon/connector/ergon_platform/workflows/async_connector.py +224 -0
- ergon/connector/ergon_platform/workflows/connector.py +201 -0
- ergon/connector/ergon_platform/workflows/models.py +90 -0
- ergon/connector/ergon_platform/workflows/utils.py +250 -0
- ergon/connector/nylas/async_connector.py +6 -0
- ergon/connector/nylas/async_service.py +3 -0
- ergon/connector/nylas/connector.py +6 -0
- ergon/connector/nylas/models.py +8 -0
- ergon/connector/nylas/service.py +9 -0
- ergon/connector/nylas/utils.py +2 -4
- ergon/task/mixins/consumer.py +0 -2
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/METADATA +3 -3
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/RECORD +38 -21
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/WHEEL +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/entry_points.txt +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unicodedata
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from ...transaction import Transaction
|
|
9
|
+
|
|
10
|
+
ChannelName = Literal["email"]
|
|
11
|
+
DEFAULT_CHANNEL: ChannelName = "email"
|
|
12
|
+
AddressDirection = Literal["send", "receive", "both"]
|
|
13
|
+
|
|
14
|
+
INBOUND_RECEIVED_EVENT_TYPE = "channels.email.received"
|
|
15
|
+
_SUBSCRIPTION_NAMESPACE = uuid.UUID("a62613cf-d5f8-4d46-b69d-d120766942eb")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChannelsActivityFilter(BaseModel):
|
|
19
|
+
"""Filter inbox activity events by type and metadata.
|
|
20
|
+
|
|
21
|
+
Claim responses contain complete events, so filters are evaluated before a
|
|
22
|
+
transaction is handed to the task. Non-matches are acknowledged only for
|
|
23
|
+
this subscription, preventing them from starving later matching events.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
received_only: bool = Field(
|
|
27
|
+
default=True,
|
|
28
|
+
description=(
|
|
29
|
+
f"When ``True`` (default), fetch inbound emails only (``event_type={INBOUND_RECEIVED_EVENT_TYPE}``)."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
correlation_id: Optional[str] = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="Filter by platform correlation id.",
|
|
35
|
+
)
|
|
36
|
+
thread_id: Optional[str] = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
description="Filter by thread id.",
|
|
39
|
+
)
|
|
40
|
+
from_address: Optional[str] = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="Match sender email (client-side, case-insensitive).",
|
|
43
|
+
)
|
|
44
|
+
subject_contains: Optional[str] = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="Substring match on subject (client-side; case and accent insensitive).",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def has_client_side_filters(self) -> bool:
|
|
51
|
+
return self.from_address is not None or self.subject_contains is not None
|
|
52
|
+
|
|
53
|
+
def activity_query_params(self) -> Dict[str, Any]:
|
|
54
|
+
"""Build server-side activity query params from this filter."""
|
|
55
|
+
params: Dict[str, Any] = {"channel": DEFAULT_CHANNEL}
|
|
56
|
+
if self.received_only:
|
|
57
|
+
params["event_type"] = INBOUND_RECEIVED_EVENT_TYPE
|
|
58
|
+
if self.correlation_id is not None:
|
|
59
|
+
params["correlation_id"] = self.correlation_id
|
|
60
|
+
if self.thread_id is not None:
|
|
61
|
+
params["thread_id"] = self.thread_id
|
|
62
|
+
return params
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _field(transaction: Transaction, key: str) -> Any:
|
|
66
|
+
metadata = transaction.metadata or {}
|
|
67
|
+
if key in metadata:
|
|
68
|
+
return metadata[key]
|
|
69
|
+
payload = transaction.payload
|
|
70
|
+
if isinstance(payload, dict):
|
|
71
|
+
return payload.get(key)
|
|
72
|
+
return getattr(payload, key, None)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _fold_text(value: str) -> str:
|
|
76
|
+
"""Lowercase and strip combining marks so ``código`` matches ``codigo``."""
|
|
77
|
+
decomposed = unicodedata.normalize("NFKD", value)
|
|
78
|
+
return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).lower()
|
|
79
|
+
|
|
80
|
+
def matches(self, transaction: Transaction) -> bool:
|
|
81
|
+
"""Return True when *transaction* satisfies this subscription filter."""
|
|
82
|
+
if self.received_only:
|
|
83
|
+
event_type = str(self._field(transaction, "event_type") or "")
|
|
84
|
+
if event_type and event_type != INBOUND_RECEIVED_EVENT_TYPE:
|
|
85
|
+
return False
|
|
86
|
+
if self.correlation_id is not None:
|
|
87
|
+
if str(self._field(transaction, "correlation_id") or "") != self.correlation_id:
|
|
88
|
+
return False
|
|
89
|
+
if self.thread_id is not None:
|
|
90
|
+
if str(self._field(transaction, "thread_id") or "") != self.thread_id:
|
|
91
|
+
return False
|
|
92
|
+
if self.from_address is not None:
|
|
93
|
+
sender = str(self._field(transaction, "from_address") or "").strip().lower()
|
|
94
|
+
if sender != self.from_address.strip().lower():
|
|
95
|
+
return False
|
|
96
|
+
if self.subject_contains is not None:
|
|
97
|
+
subject = str(self._field(transaction, "subject") or "")
|
|
98
|
+
if self._fold_text(self.subject_contains) not in self._fold_text(subject):
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def select(self, transactions: List[Transaction]) -> List[Transaction]:
|
|
103
|
+
"""Select transactions that satisfy the client-side filter fields."""
|
|
104
|
+
if not self.has_client_side_filters:
|
|
105
|
+
return transactions
|
|
106
|
+
return [tx for tx in transactions if self.matches(tx)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ResolvedInboxAddress(BaseModel):
|
|
110
|
+
"""Inbox routing metadata resolved from ``channels.addresses`` or config activity."""
|
|
111
|
+
|
|
112
|
+
address: str
|
|
113
|
+
address_id: str
|
|
114
|
+
config_id: str
|
|
115
|
+
direction: AddressDirection = "both"
|
|
116
|
+
config_name: Optional[str] = Field(
|
|
117
|
+
default=None,
|
|
118
|
+
description="Channel config display name from ``configs.verify`` when available.",
|
|
119
|
+
)
|
|
120
|
+
config_can_send: Optional[bool] = Field(
|
|
121
|
+
default=None,
|
|
122
|
+
description="Outbound capability from ``configs.verify`` (``capabilities.can_send``).",
|
|
123
|
+
)
|
|
124
|
+
config_can_receive: Optional[bool] = Field(
|
|
125
|
+
default=None,
|
|
126
|
+
description="Inbound capability from ``configs.verify`` (``inbound_enabled``).",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def can_receive(self) -> bool:
|
|
131
|
+
if self.config_can_receive is False:
|
|
132
|
+
return False
|
|
133
|
+
return self.direction in ("receive", "both")
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def can_send(self) -> bool:
|
|
137
|
+
if self.config_can_send is False:
|
|
138
|
+
return False
|
|
139
|
+
return self.direction in ("send", "both")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def mode(self) -> str:
|
|
143
|
+
"""Human-readable summary of what this inbox supports."""
|
|
144
|
+
if self.can_receive and self.can_send:
|
|
145
|
+
return "receive and send"
|
|
146
|
+
if self.can_receive:
|
|
147
|
+
return "receive-only"
|
|
148
|
+
if self.can_send:
|
|
149
|
+
return "send-only"
|
|
150
|
+
return "inactive"
|
|
151
|
+
|
|
152
|
+
def _channel_label(self) -> str:
|
|
153
|
+
if self.config_name and self.config_id:
|
|
154
|
+
return f"{self.config_name!r} ({self.config_id})"
|
|
155
|
+
if self.config_name:
|
|
156
|
+
return repr(self.config_name)
|
|
157
|
+
if self.config_id:
|
|
158
|
+
return self.config_id
|
|
159
|
+
return "unknown channel"
|
|
160
|
+
|
|
161
|
+
def ensure_can_receive(self) -> None:
|
|
162
|
+
"""Raise a clear error when this inbox cannot be used as a consumer."""
|
|
163
|
+
if self.can_receive:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
f"Cannot fetch from inbox {self.address!r}: capability is {self.mode}.",
|
|
168
|
+
" Consumer expects: receive or both.",
|
|
169
|
+
]
|
|
170
|
+
if self.config_id:
|
|
171
|
+
lines.append(f" Channel config: {self._channel_label()}")
|
|
172
|
+
lines.append(f" Address direction: {self.direction}")
|
|
173
|
+
if self.config_can_receive is not None or self.config_can_send is not None:
|
|
174
|
+
lines.append(f" Platform: inbound_enabled={self.config_can_receive}, can_send={self.config_can_send}")
|
|
175
|
+
|
|
176
|
+
if self.config_can_receive is False:
|
|
177
|
+
lines.append(
|
|
178
|
+
"Fix: use ErgonPlatformChannelsConsumerConfig with a channel that has "
|
|
179
|
+
"inbound enabled (receive or both)."
|
|
180
|
+
)
|
|
181
|
+
elif self.direction == "send":
|
|
182
|
+
lines.append("Fix: this address is send-only — point consumer_config to a receive or both inbox.")
|
|
183
|
+
else:
|
|
184
|
+
lines.append("Fix: use ErgonPlatformChannelsConsumerConfig with a receive-capable inbox.")
|
|
185
|
+
raise ValueError("\n".join(lines))
|
|
186
|
+
|
|
187
|
+
def ensure_can_send(self) -> None:
|
|
188
|
+
"""Raise a clear error when this inbox cannot be used as a producer."""
|
|
189
|
+
if self.can_send:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
lines = [
|
|
193
|
+
f"Cannot send from inbox {self.address!r}: capability is {self.mode}.",
|
|
194
|
+
" Producer expects: send or both.",
|
|
195
|
+
]
|
|
196
|
+
if self.config_id:
|
|
197
|
+
lines.append(f" Channel config: {self._channel_label()}")
|
|
198
|
+
lines.append(f" Address direction: {self.direction}")
|
|
199
|
+
if self.config_can_receive is not None or self.config_can_send is not None:
|
|
200
|
+
lines.append(f" Platform: inbound_enabled={self.config_can_receive}, can_send={self.config_can_send}")
|
|
201
|
+
|
|
202
|
+
if self.config_can_send is False:
|
|
203
|
+
lines.append(
|
|
204
|
+
"Fix: use ErgonPlatformChannelsProducerConfig with a different channel "
|
|
205
|
+
"config that allows send (can_send=true). "
|
|
206
|
+
"In the example app, set CHANNELS_SEND_CONFIG_ID / CHANNELS_SEND_ADDRESS."
|
|
207
|
+
)
|
|
208
|
+
elif self.direction == "receive":
|
|
209
|
+
lines.append("Fix: this address is receive-only — point producer_config to a send or both inbox.")
|
|
210
|
+
else:
|
|
211
|
+
lines.append("Fix: use ErgonPlatformChannelsProducerConfig with a send-capable inbox.")
|
|
212
|
+
raise ValueError("\n".join(lines))
|
|
213
|
+
|
|
214
|
+
def as_info_dict(self) -> Dict[str, Any]:
|
|
215
|
+
return {
|
|
216
|
+
"address": self.address,
|
|
217
|
+
"address_id": self.address_id,
|
|
218
|
+
"config_id": self.config_id,
|
|
219
|
+
"config_name": self.config_name,
|
|
220
|
+
"direction": self.direction,
|
|
221
|
+
"mode": self.mode,
|
|
222
|
+
"config_can_send": self.config_can_send,
|
|
223
|
+
"config_can_receive": self.config_can_receive,
|
|
224
|
+
"can_receive": self.can_receive,
|
|
225
|
+
"can_send": self.can_send,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def parse_direction(cls, value: Any, *, default: AddressDirection = "both") -> AddressDirection:
|
|
230
|
+
"""Parse an address direction from a string."""
|
|
231
|
+
raw = str(value or default).strip().lower()
|
|
232
|
+
if raw in ("send", "receive", "both"):
|
|
233
|
+
return raw
|
|
234
|
+
raise ValueError(f"Unsupported address direction: {value!r} (expected send, receive or both)")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ErgonPlatformChannelsConfig(BaseModel):
|
|
238
|
+
"""Connect to one channels inbox."""
|
|
239
|
+
|
|
240
|
+
address: str = Field(min_length=3, description="Inbox email from the console.")
|
|
241
|
+
config_id: str = Field(min_length=1, description="Channel UUID from the console URL.")
|
|
242
|
+
send_address: Optional[str] = Field(
|
|
243
|
+
default=None,
|
|
244
|
+
description="Separate sender inbox; defaults to ``address``.",
|
|
245
|
+
)
|
|
246
|
+
batch_size: int = Field(default=50, ge=1, description="Max emails per fetch.")
|
|
247
|
+
subscription_id: Optional[str] = Field(
|
|
248
|
+
default=None,
|
|
249
|
+
description="Optional stable inbox subscription UUID.",
|
|
250
|
+
)
|
|
251
|
+
consumer_id: str = Field(default="ergon-framework", min_length=1, max_length=255)
|
|
252
|
+
visibility_timeout_seconds: int = Field(default=300, ge=1, le=3600)
|
|
253
|
+
nack_delay_seconds: int = Field(default=0, ge=0)
|
|
254
|
+
received_only: bool = Field(
|
|
255
|
+
default=True,
|
|
256
|
+
description=f"Fetch inbound emails only (``{INBOUND_RECEIVED_EVENT_TYPE}``).",
|
|
257
|
+
)
|
|
258
|
+
activity_filter: Optional[ChannelsActivityFilter] = Field(
|
|
259
|
+
default=None,
|
|
260
|
+
description="Advanced activity filter; overrides ``received_only`` when set.",
|
|
261
|
+
)
|
|
262
|
+
download_attachments: bool = Field(
|
|
263
|
+
default=False,
|
|
264
|
+
description="Download attachment bytes onto each fetched Transaction.",
|
|
265
|
+
)
|
|
266
|
+
attachment_failure_policy: Literal["raise", "best_effort"] = "raise"
|
|
267
|
+
attachment_download_timeout: float = Field(
|
|
268
|
+
default=20.0,
|
|
269
|
+
ge=1.0,
|
|
270
|
+
le=120.0,
|
|
271
|
+
description="HTTP timeout in seconds for each inbound attachment download.",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def to_consumer_config(self) -> "ErgonPlatformChannelsConsumerConfig":
|
|
275
|
+
return ErgonPlatformChannelsConsumerConfig(
|
|
276
|
+
address=self.address,
|
|
277
|
+
config_id=self.config_id,
|
|
278
|
+
batch_size=self.batch_size,
|
|
279
|
+
subscription_id=self.subscription_id,
|
|
280
|
+
consumer_id=self.consumer_id,
|
|
281
|
+
visibility_timeout_seconds=self.visibility_timeout_seconds,
|
|
282
|
+
nack_delay_seconds=self.nack_delay_seconds,
|
|
283
|
+
received_only=self.received_only,
|
|
284
|
+
activity_filter=self.activity_filter,
|
|
285
|
+
download_attachments=self.download_attachments,
|
|
286
|
+
attachment_failure_policy=self.attachment_failure_policy,
|
|
287
|
+
attachment_download_timeout=self.attachment_download_timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def to_producer_config(self) -> "ErgonPlatformChannelsProducerConfig":
|
|
291
|
+
return ErgonPlatformChannelsProducerConfig(
|
|
292
|
+
address=self.send_address or self.address,
|
|
293
|
+
config_id=self.config_id,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class ErgonPlatformChannelsConsumerConfig(BaseModel):
|
|
298
|
+
"""Read inbound email from one inbox."""
|
|
299
|
+
|
|
300
|
+
address: str = Field(
|
|
301
|
+
min_length=3,
|
|
302
|
+
description="Inbox email to read from.",
|
|
303
|
+
)
|
|
304
|
+
config_id: Optional[str] = Field(
|
|
305
|
+
default=None,
|
|
306
|
+
description="Channel UUID from the console URL. Set together with ``address`` (recommended).",
|
|
307
|
+
)
|
|
308
|
+
batch_size: int = Field(default=50, ge=1, description="Max events per fetch.")
|
|
309
|
+
subscription_id: Optional[str] = Field(
|
|
310
|
+
default=None,
|
|
311
|
+
description=(
|
|
312
|
+
"Stable inbox subscription UUID. When omitted, the connector derives one "
|
|
313
|
+
"from the channel, address, and filter contract so credential rotation "
|
|
314
|
+
"does not reset delivery state."
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
consumer_id: str = Field(
|
|
318
|
+
default="ergon-framework",
|
|
319
|
+
min_length=1,
|
|
320
|
+
max_length=255,
|
|
321
|
+
description="Worker/replica label recorded on the current delivery lease.",
|
|
322
|
+
)
|
|
323
|
+
visibility_timeout_seconds: int = Field(
|
|
324
|
+
default=300,
|
|
325
|
+
ge=1,
|
|
326
|
+
le=3600,
|
|
327
|
+
description="Duration of each atomic claim before another replica may retry it.",
|
|
328
|
+
)
|
|
329
|
+
claim_page_size: int = Field(
|
|
330
|
+
default=100,
|
|
331
|
+
ge=1,
|
|
332
|
+
le=100,
|
|
333
|
+
description="Claim page size used while scanning past filtered events.",
|
|
334
|
+
)
|
|
335
|
+
nack_delay_seconds: int = Field(
|
|
336
|
+
default=0,
|
|
337
|
+
ge=0,
|
|
338
|
+
description="Default delay applied by ``nack_transaction(requeue=True)``.",
|
|
339
|
+
)
|
|
340
|
+
deduplicate_fetched_events: bool = Field(
|
|
341
|
+
default=False,
|
|
342
|
+
description=(
|
|
343
|
+
"Optional in-process skip of already-fetched ids. Prefer the platform "
|
|
344
|
+
"subscription claim state — this is only a local fallback."
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
received_only: bool = Field(
|
|
348
|
+
default=True,
|
|
349
|
+
description=(
|
|
350
|
+
f"When ``True`` (default), fetch inbound emails only (``event_type={INBOUND_RECEIVED_EVENT_TYPE}``)."
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
activity_filter: Optional[ChannelsActivityFilter] = Field(
|
|
354
|
+
default=None,
|
|
355
|
+
description="Advanced activity filter; overrides ``received_only`` when set.",
|
|
356
|
+
)
|
|
357
|
+
download_attachments: bool = Field(
|
|
358
|
+
default=False,
|
|
359
|
+
description="Download attachment bytes onto each fetched Transaction (like Nylas).",
|
|
360
|
+
)
|
|
361
|
+
attachment_failure_policy: Literal["raise", "best_effort"] = Field(
|
|
362
|
+
default="raise",
|
|
363
|
+
description=(
|
|
364
|
+
"Attachment failure policy. 'raise' nacks every claim in the failed fetch; "
|
|
365
|
+
"'best_effort' exposes attachment_failures and blocks ack unless explicitly overridden."
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
attachment_download_timeout: float = Field(
|
|
369
|
+
default=20.0,
|
|
370
|
+
ge=1.0,
|
|
371
|
+
le=120.0,
|
|
372
|
+
description=(
|
|
373
|
+
"Per-file HTTP timeout for attachment downloads. Uses a dedicated client "
|
|
374
|
+
"(no retries) so a stuck Resend CDN cannot block ack/nack."
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def effective_activity_filter(self) -> ChannelsActivityFilter:
|
|
379
|
+
if self.activity_filter is not None:
|
|
380
|
+
return self.activity_filter
|
|
381
|
+
return ChannelsActivityFilter(received_only=self.received_only)
|
|
382
|
+
|
|
383
|
+
def activity_query_params(self) -> Dict[str, Any]:
|
|
384
|
+
"""Build the immutable filter identity for this subscription."""
|
|
385
|
+
params = self.effective_activity_filter().activity_query_params()
|
|
386
|
+
return params
|
|
387
|
+
|
|
388
|
+
def resolved_subscription_id(self, config_id: str, address_id: str) -> str:
|
|
389
|
+
"""Return an explicit or deterministic subscription UUID."""
|
|
390
|
+
|
|
391
|
+
if self.subscription_id is not None:
|
|
392
|
+
return str(uuid.UUID(self.subscription_id))
|
|
393
|
+
identity = json.dumps(
|
|
394
|
+
{
|
|
395
|
+
"config_id": config_id,
|
|
396
|
+
"address_id": address_id,
|
|
397
|
+
"filter": self.effective_activity_filter().model_dump(mode="json"),
|
|
398
|
+
},
|
|
399
|
+
sort_keys=True,
|
|
400
|
+
separators=(",", ":"),
|
|
401
|
+
default=str,
|
|
402
|
+
)
|
|
403
|
+
return str(uuid.uuid5(_SUBSCRIPTION_NAMESPACE, identity))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class ErgonPlatformChannelsProducerConfig(BaseModel):
|
|
407
|
+
"""Defaults for outbound send."""
|
|
408
|
+
|
|
409
|
+
address: Optional[str] = Field(
|
|
410
|
+
default=None,
|
|
411
|
+
description="Sender inbox email. Falls back to the consumer inbox when unset.",
|
|
412
|
+
)
|
|
413
|
+
config_id: Optional[str] = Field(
|
|
414
|
+
default=None,
|
|
415
|
+
description="Channel UUID for resolving the sender. Falls back to the consumer config.",
|
|
416
|
+
)
|
|
417
|
+
service_name: Optional[str] = Field(default=None, description="Optional tag on send.")
|
|
418
|
+
default_reply_to: Optional[str] = Field(default=None, description="Default Reply-To header on send.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class SendMessageAttachment(BaseModel):
|
|
422
|
+
"""File attachment for a channels send payload."""
|
|
423
|
+
|
|
424
|
+
filename: str = Field(description="Displayed filename")
|
|
425
|
+
content_type: str = Field(description="MIME type")
|
|
426
|
+
content: str = Field(description="Base64-encoded content (SDK-friendly transport)")
|
|
427
|
+
|
|
428
|
+
model_config = {"populate_by_name": True}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class InboxAttachmentFile(BaseModel):
|
|
432
|
+
"""Inbound attachment downloaded from a fetched inbox event."""
|
|
433
|
+
|
|
434
|
+
attachment_id: str
|
|
435
|
+
filename: str
|
|
436
|
+
content: bytes
|
|
437
|
+
content_type: Optional[str] = None
|
|
438
|
+
path: Optional[str] = Field(
|
|
439
|
+
default=None,
|
|
440
|
+
description="Local path when ``download_attachments(..., dest=...)`` wrote the file.",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SendMessageInput(BaseModel):
|
|
445
|
+
"""Email content for ``send_email`` / ``dispatch_transactions``."""
|
|
446
|
+
|
|
447
|
+
to: List[str] = Field(default_factory=list, description="Recipients")
|
|
448
|
+
subject: Optional[str] = Field(default=None, description="Subject")
|
|
449
|
+
html: Optional[str] = Field(default=None, description="HTML body")
|
|
450
|
+
text: Optional[str] = Field(default=None, description="Plain-text body")
|
|
451
|
+
cc: Optional[List[str]] = Field(default=None, description="CC")
|
|
452
|
+
bcc: Optional[List[str]] = Field(default=None, description="BCC")
|
|
453
|
+
reply_to: Optional[str] = Field(default=None, description="Reply-To header")
|
|
454
|
+
in_reply_to: Optional[str] = Field(default=None, description="Message-Id being replied to")
|
|
455
|
+
attachments: Optional[List[SendMessageAttachment]] = Field(default=None, description="Attachments")
|
|
456
|
+
|
|
457
|
+
model_config = {"populate_by_name": True}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
SendMessagePayload = Union[SendMessageInput, Dict[str, Any]]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from .activity import ChannelsActivityService
|
|
6
|
+
from .addresses import ChannelsAddressService
|
|
7
|
+
from .attachments import ChannelsAttachmentService
|
|
8
|
+
from .messages import ChannelsMessageService
|
|
9
|
+
from .service import ErgonPlatformChannelsService
|
|
10
|
+
|
|
11
|
+
_EXPORTS = {
|
|
12
|
+
"ChannelsActivityService": "activity",
|
|
13
|
+
"ChannelsAddressService": "addresses",
|
|
14
|
+
"ChannelsAttachmentService": "attachments",
|
|
15
|
+
"ChannelsMessageService": "messages",
|
|
16
|
+
"ErgonPlatformChannelsService": "service",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> Any:
|
|
21
|
+
module_name = _EXPORTS.get(name)
|
|
22
|
+
if module_name is None:
|
|
23
|
+
raise AttributeError(name)
|
|
24
|
+
value = getattr(import_module(f"{__name__}.{module_name}"), name)
|
|
25
|
+
globals()[name] = value
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __dir__() -> list[str]:
|
|
30
|
+
return sorted({*globals(), *_EXPORTS})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ChannelsActivityService",
|
|
35
|
+
"ChannelsAddressService",
|
|
36
|
+
"ChannelsAttachmentService",
|
|
37
|
+
"ChannelsMessageService",
|
|
38
|
+
"ErgonPlatformChannelsService",
|
|
39
|
+
]
|