dex-connector-postmark 0.1.0__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,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: dex-connector-postmark
3
+ Version: 0.1.0
4
+ Summary: Postmark connector for Dex Flows
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: dex-connectors-sdk==0.1.0
8
+ Requires-Dist: dex-python-sdk>=1.28.0
9
+ Requires-Dist: httpx>=0.27
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Postmark connector for Python
13
+
14
+ Install with `pip install dex-connector-postmark==0.1.0`. The package requires
15
+ `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`, and implements the
16
+ manifest Queries `listOutboundMessages` and `getMessageDetails` with the same
17
+ branches, defaults, and failure classification as the Go module.
18
+
19
+ ## Operations
20
+
21
+ | Operation | Factory | Happy branch | Optional branches |
22
+ | --- | --- | --- | --- |
23
+ | `listOutboundMessages` | `new_list_outbound_messages_step` | `listed` | `providerRejected`, `invalidResponse`, `defect` |
24
+ | `getMessageDetails` | `new_get_message_details_step` | `found` | `notFound`, `providerRejected`, `invalidResponse`, `defect` |
25
+
26
+ `listOutboundMessages` sends `count` (zero means 50, at most 500) and `offset`;
27
+ `count + offset` must stay within Postmark's 10,000-message search window.
28
+ `getMessageDetails` selects `notFound` for HTTP 404 or Postmark error code 701.
29
+ 408, 429, 5xx, and transport failures retry; other 4xx responses select
30
+ `providerRejected`.
31
+
32
+ ## Authorization
33
+
34
+ Create a Dex Web connection named `postmark` as described in the
35
+ [connector README](../README.md#authorization). The Server API token travels
36
+ only in the `X-Postmark-Server-Token` header and is resolved for every attempt.
37
+
38
+ ## Example
39
+
40
+ [`examples/outbound_lookup`](examples/outbound_lookup/flow.py) checks the page
41
+ window, lists outbound messages with `new_list_outbound_messages_step`, and
42
+ records the page. Run the Worker with
43
+ `uv run python -m examples.outbound_lookup.worker` after `dexcli dev` has
44
+ loaded a project containing the `postmark` connection.
45
+
46
+ ## Tests
47
+
48
+ `postmarkmock.Mock` scripts both Queries and defaults to the manifest mock
49
+ answers. Run `uv run pytest tests` for provider parity and manifest contract
50
+ checks.
@@ -0,0 +1,11 @@
1
+ dex_connectors/postmark/__init__.py,sha256=_-6rphGp1B5jm4pplvG41pEQyN0rdcYIcwd1sfKYkOc,1368
2
+ dex_connectors/postmark/client.py,sha256=fr0F7Top4DMusNlhq7reqnGhOnu1dvCVfYvaq9uaXwY,14296
3
+ dex_connectors/postmark/connection.py,sha256=_zzu6nP9iCV5vk1S6HOeOWHRV1h2-TNScpsijXQt5SY,5746
4
+ dex_connectors/postmark/dex-connector.json,sha256=lCZYDCAHAxL8WjAJN1YMy5vOonwcYWYkg8M63TmfBR4,1906
5
+ dex_connectors/postmark/models.py,sha256=oBWOKOh2PZOZmNv7Z_oIr6AqvEcu0I7lUwJnR3xo_wg,1252
6
+ dex_connectors/postmark/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ dex_connectors/postmark/steps.py,sha256=FYDZ8aRLGkaqq3nmoyBkA_yxidKmX_qOsxTUli3Q6nU,4995
8
+ dex_connectors/postmark/postmarkmock/__init__.py,sha256=mNgxsoSFW28qfRkhRhgWk5Ff5IXEwxYNc_wCHckzlVg,3219
9
+ dex_connector_postmark-0.1.0.dist-info/METADATA,sha256=eoQLvRX6tzkY6m8S0f1OQ8L4Wsz4Ma07S3iT6jTKP0o,2064
10
+ dex_connector_postmark-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ dex_connector_postmark-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,50 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Postmark outbound message operations for Dex connector Steps."""
4
+
5
+ from .client import (
6
+ CONNECTOR_ID,
7
+ DEFECT,
8
+ FOUND,
9
+ GET_MESSAGE_DETAILS_DEFINITION,
10
+ INVALID_RESPONSE,
11
+ LIST_OUTBOUND_MESSAGES_DEFINITION,
12
+ LISTED,
13
+ NOT_FOUND,
14
+ PROVIDER_REJECTED,
15
+ Client,
16
+ Config,
17
+ Credentials,
18
+ GetMessageDetailsOperation,
19
+ ListOutboundMessagesOperation,
20
+ )
21
+ from .connection import Connection, new_connection, new_connection_with_operations, new_project_connection
22
+ from .models import GetMessageDetailsInput, ListOutboundMessagesInput, MessageDetails, MessagePage
23
+ from .steps import new_get_message_details_step, new_list_outbound_messages_step
24
+
25
+ __all__ = [
26
+ "Client",
27
+ "Config",
28
+ "Connection",
29
+ "Credentials",
30
+ "GetMessageDetailsInput",
31
+ "GetMessageDetailsOperation",
32
+ "ListOutboundMessagesInput",
33
+ "ListOutboundMessagesOperation",
34
+ "MessageDetails",
35
+ "MessagePage",
36
+ "new_connection",
37
+ "new_connection_with_operations",
38
+ "new_get_message_details_step",
39
+ "new_list_outbound_messages_step",
40
+ "new_project_connection",
41
+ "CONNECTOR_ID",
42
+ "DEFECT",
43
+ "FOUND",
44
+ "GET_MESSAGE_DETAILS_DEFINITION",
45
+ "INVALID_RESPONSE",
46
+ "LIST_OUTBOUND_MESSAGES_DEFINITION",
47
+ "LISTED",
48
+ "NOT_FOUND",
49
+ "PROVIDER_REJECTED",
50
+ ]
@@ -0,0 +1,375 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Postmark provider calls and Go-compatible branch classification."""
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any, TypeVar
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+ from dex_connectors.sdk import (
14
+ BranchDefinition,
15
+ Call,
16
+ CredentialProvider,
17
+ Failure,
18
+ FailureKind,
19
+ OperationRef,
20
+ QueryAttempt,
21
+ QueryDefinition,
22
+ ReauthorizationRequiredError,
23
+ Receipt,
24
+ SecretString,
25
+ StepDefaults,
26
+ )
27
+ from dex_connectors.sdk.providerhttp import (
28
+ MAX_ERROR_BODY_BYTES,
29
+ BodyTooLargeError,
30
+ ProviderTransportError,
31
+ is_header_safe_credential,
32
+ new_provider_http_client,
33
+ parse_retry_after,
34
+ read_bounded_body,
35
+ send_traced,
36
+ validate_base_url,
37
+ )
38
+ from dex_connectors.sdk.retry_policy import default_connector_step_execute_retry_policy
39
+
40
+ from .models import GetMessageDetailsInput, ListOutboundMessagesInput, MessageDetails, MessagePage
41
+
42
+ CONNECTOR_ID = "postmark"
43
+ LIST_OUTBOUND_MESSAGES_OPERATION_ID = "listOutboundMessages"
44
+ GET_MESSAGE_DETAILS_OPERATION_ID = "getMessageDetails"
45
+ LISTED = "listed"
46
+ FOUND = "found"
47
+ NOT_FOUND = "notFound"
48
+ PROVIDER_REJECTED = "providerRejected"
49
+ INVALID_RESPONSE = "invalidResponse"
50
+ DEFECT = "defect"
51
+ _DEFAULT_BASE_URL = "https://api.postmarkapp.com"
52
+ _DEFAULT_TIMEOUT_SECONDS = 15
53
+ _DEFAULT_MAX_RESPONSE_BYTES = 2_097_152
54
+ _MESSAGE_NOT_FOUND_ERROR_CODE = 701
55
+ _STEP_DEFAULTS = StepDefaults(
56
+ timedelta(seconds=30), 2, execute_retry=default_connector_step_execute_retry_policy()
57
+ )
58
+ _SHARED_BRANCHES = (
59
+ BranchDefinition(PROVIDER_REJECTED, "The provider definitively rejected the request.", True),
60
+ BranchDefinition(INVALID_RESPONSE, "The provider returned malformed or oversized JSON.", True),
61
+ BranchDefinition(DEFECT, "Local input or connection credentials are invalid.", True),
62
+ )
63
+ LIST_OUTBOUND_MESSAGES_DEFINITION = QueryDefinition(
64
+ OperationRef(CONNECTOR_ID, LIST_OUTBOUND_MESSAGES_OPERATION_ID),
65
+ (BranchDefinition(LISTED, "Postmark returned the requested result."), *_SHARED_BRANCHES),
66
+ _STEP_DEFAULTS,
67
+ )
68
+ GET_MESSAGE_DETAILS_DEFINITION = QueryDefinition(
69
+ OperationRef(CONNECTOR_ID, GET_MESSAGE_DETAILS_OPERATION_ID),
70
+ (
71
+ BranchDefinition(FOUND, "Postmark returned the requested result."),
72
+ BranchDefinition(NOT_FOUND, "The requested resource does not exist.", True),
73
+ *_SHARED_BRANCHES,
74
+ ),
75
+ _STEP_DEFAULTS,
76
+ )
77
+
78
+ Output = TypeVar("Output")
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Config:
83
+ """Postmark HTTP settings; zero or blank values use manifest defaults."""
84
+
85
+ base_url: str = _DEFAULT_BASE_URL
86
+ timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS
87
+ max_response_bytes: int = _DEFAULT_MAX_RESPONSE_BYTES
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class Credentials:
92
+ """Postmark Server API token, resolved anew for every provider attempt."""
93
+
94
+ api_key: SecretString
95
+
96
+
97
+ class Client:
98
+ """One redirect-safe HTTP client and call-time credential source."""
99
+
100
+ def __init__(
101
+ self,
102
+ config: Config,
103
+ credentials: CredentialProvider[Credentials],
104
+ http_client: httpx.AsyncClient | None = None,
105
+ ) -> None:
106
+ if config is None:
107
+ raise ValueError("Postmark configuration is required")
108
+ if credentials is None:
109
+ raise ValueError("Postmark credential source is required")
110
+ self.base_url = validate_base_url(config.base_url or _DEFAULT_BASE_URL)
111
+ if config.timeout_seconds < 0 or config.max_response_bytes < 0:
112
+ raise ValueError("Postmark timeout and max response bytes must be positive")
113
+ self.max_response_bytes = config.max_response_bytes or _DEFAULT_MAX_RESPONSE_BYTES
114
+ self.credentials = credentials
115
+ self.http_client = http_client or new_provider_http_client(
116
+ config.timeout_seconds or _DEFAULT_TIMEOUT_SECONDS
117
+ )
118
+
119
+ def list_outbound_messages(self) -> "ListOutboundMessagesOperation":
120
+ """Bind the outbound message search Query to this client."""
121
+ return ListOutboundMessagesOperation(self)
122
+
123
+ def get_message_details(self) -> "GetMessageDetailsOperation":
124
+ """Bind the message details Query to this client."""
125
+ return GetMessageDetailsOperation(self)
126
+
127
+ async def aclose(self) -> None:
128
+ """Close the owned provider HTTP client after application shutdown."""
129
+ await self.http_client.aclose()
130
+
131
+ async def _run_query(
132
+ self,
133
+ call: Call,
134
+ operation: str,
135
+ path: str,
136
+ empty: Output,
137
+ success: str,
138
+ decode: Callable[[dict[str, Any]], Output],
139
+ is_message_lookup: bool = False,
140
+ ) -> QueryAttempt[Output]:
141
+ """Send one GET and classify it exactly like the Go runQuery helper."""
142
+ try:
143
+ credentials = await self.credentials.resolve(call)
144
+ except (ValueError, LookupError, OSError, ReauthorizationRequiredError):
145
+ return _branch(
146
+ operation, DEFECT, empty, FailureKind.AUTHENTICATION, "connection credentials are unavailable"
147
+ )
148
+ token = credentials.api_key.reveal()
149
+ if not token.strip() or not is_header_safe_credential(token):
150
+ return _branch(
151
+ operation, DEFECT, empty, FailureKind.AUTHENTICATION, "connection credentials are invalid"
152
+ )
153
+ headers = {
154
+ "X-Postmark-Server-Token": token,
155
+ "Accept": "application/json",
156
+ "User-Agent": "superdurable-postmark/0.1",
157
+ }
158
+ try:
159
+ request = self.http_client.build_request("GET", self.base_url + path, headers=headers)
160
+ except (httpx.HTTPError, ValueError):
161
+ return _branch(
162
+ operation, DEFECT, empty, FailureKind.LOCAL_DEFECT, "Postmark request could not be built"
163
+ )
164
+ try:
165
+ response, _ = await send_traced(self.http_client, request)
166
+ except ProviderTransportError:
167
+ return QueryAttempt.retry(
168
+ _failure(operation, FailureKind.AVAILABILITY, "provider request failed")
169
+ )
170
+ try:
171
+ status = response.status_code
172
+ if status == 429:
173
+ return QueryAttempt.retry(
174
+ _failure(operation, FailureKind.RATE_LIMIT, "provider rate limited the request"),
175
+ _retry_after_seconds(response),
176
+ )
177
+ if status == 408 or status >= 500:
178
+ return QueryAttempt.retry(
179
+ _failure(operation, FailureKind.AVAILABILITY, "provider is temporarily unavailable"),
180
+ _retry_after_seconds(response),
181
+ )
182
+ if is_message_lookup and status == 422:
183
+ if await _is_message_not_found(response):
184
+ return _branch(
185
+ operation, NOT_FOUND, empty, FailureKind.NOT_FOUND, "Postmark message was not found"
186
+ )
187
+ return _branch(
188
+ operation,
189
+ PROVIDER_REJECTED,
190
+ empty,
191
+ FailureKind.PROVIDER_REJECTION,
192
+ "Postmark rejected the message request",
193
+ )
194
+ if status == 404 and is_message_lookup:
195
+ return _branch(
196
+ operation, NOT_FOUND, empty, FailureKind.NOT_FOUND, "provider resource was not found"
197
+ )
198
+ if status != 200:
199
+ return _branch(
200
+ operation,
201
+ PROVIDER_REJECTED,
202
+ empty,
203
+ FailureKind.PROVIDER_REJECTION,
204
+ f"provider rejected the request with HTTP {status}",
205
+ )
206
+ try:
207
+ content = await read_bounded_body(response, self.max_response_bytes)
208
+ except BodyTooLargeError:
209
+ return _branch(
210
+ operation,
211
+ INVALID_RESPONSE,
212
+ empty,
213
+ FailureKind.RESPONSE_TOO_LARGE,
214
+ "provider response exceeded the configured limit",
215
+ )
216
+ except ProviderTransportError:
217
+ return QueryAttempt.retry(
218
+ _failure(operation, FailureKind.AVAILABILITY, "provider response was interrupted")
219
+ )
220
+ try:
221
+ document = json.loads(content)
222
+ if not isinstance(document, dict):
223
+ raise ValueError
224
+ value = decode(document)
225
+ except (ValueError, TypeError, UnicodeDecodeError):
226
+ return _branch(
227
+ operation,
228
+ INVALID_RESPONSE,
229
+ empty,
230
+ FailureKind.PROTOCOL,
231
+ "provider response is incomplete or malformed",
232
+ )
233
+ return QueryAttempt.branch_result(success, value, Receipt())
234
+ finally:
235
+ await response.aclose()
236
+
237
+
238
+ class ListOutboundMessagesOperation:
239
+ """Search one page of Postmark outbound messages."""
240
+
241
+ def __init__(self, client: Client) -> None:
242
+ if client is None:
243
+ raise ValueError("Postmark client is required")
244
+ self.client = client
245
+
246
+ def definition(self) -> QueryDefinition:
247
+ """Return manifest identity, branches, and retry defaults."""
248
+ return LIST_OUTBOUND_MESSAGES_DEFINITION
249
+
250
+ async def invoke(self, call: Call, input: ListOutboundMessagesInput) -> QueryAttempt[MessagePage]:
251
+ """Validate the search window, read once, then select a branch or safe retry."""
252
+ operation = LIST_OUTBOUND_MESSAGES_OPERATION_ID
253
+ if (
254
+ not isinstance(input, ListOutboundMessagesInput)
255
+ or type(input.count) is not int
256
+ or type(input.offset) is not int
257
+ ):
258
+ return _branch(
259
+ operation, DEFECT, MessagePage(), FailureKind.VALIDATION, "search input is invalid"
260
+ )
261
+ count = input.count or 50
262
+ if count < 1 or count > 500 or input.offset < 0 or count + input.offset > 10_000:
263
+ return _branch(
264
+ operation,
265
+ DEFECT,
266
+ MessagePage(),
267
+ FailureKind.VALIDATION,
268
+ "count and offset must fit Postmark's 10,000-message search window",
269
+ )
270
+ path = f"/messages/outbound?count={count}&offset={input.offset}"
271
+ return await self.client._run_query(
272
+ call, operation, path, MessagePage(), LISTED, _decode_message_page
273
+ )
274
+
275
+
276
+ class GetMessageDetailsOperation:
277
+ """Read one Postmark outbound message by ID."""
278
+
279
+ def __init__(self, client: Client) -> None:
280
+ if client is None:
281
+ raise ValueError("Postmark client is required")
282
+ self.client = client
283
+
284
+ def definition(self) -> QueryDefinition:
285
+ """Return manifest identity, branches, and retry defaults."""
286
+ return GET_MESSAGE_DETAILS_DEFINITION
287
+
288
+ async def invoke(self, call: Call, input: GetMessageDetailsInput) -> QueryAttempt[MessageDetails]:
289
+ """Validate the message ID, read once, then select a branch or safe retry."""
290
+ operation = GET_MESSAGE_DETAILS_OPERATION_ID
291
+ if (
292
+ not isinstance(input, GetMessageDetailsInput)
293
+ or not isinstance(input.message_id, str)
294
+ or not input.message_id
295
+ ):
296
+ return _branch(
297
+ operation, DEFECT, MessageDetails(), FailureKind.VALIDATION, "message ID is required"
298
+ )
299
+ path = "/messages/outbound/" + quote(input.message_id, safe="") + "/details"
300
+ return await self.client._run_query(
301
+ call, operation, path, MessageDetails(), FOUND, _decode_required_message, is_message_lookup=True
302
+ )
303
+
304
+
305
+ async def _is_message_not_found(response: httpx.Response) -> bool:
306
+ if response.headers.get("X-PM-ApiErrorCode") == str(_MESSAGE_NOT_FOUND_ERROR_CODE):
307
+ return True
308
+ try:
309
+ body = await read_bounded_body(response, MAX_ERROR_BODY_BYTES)
310
+ document = json.loads(body)
311
+ except (BodyTooLargeError, ProviderTransportError, ValueError, UnicodeDecodeError):
312
+ return False
313
+ code = _field(document, "ErrorCode") if isinstance(document, dict) else None
314
+ return type(code) is int and code == _MESSAGE_NOT_FOUND_ERROR_CODE
315
+
316
+
317
+ def _retry_after_seconds(response: httpx.Response) -> float:
318
+ return parse_retry_after(response.headers.get("Retry-After", ""), datetime.now(UTC))
319
+
320
+
321
+ def _failure(operation: str, kind: FailureKind, message: str) -> Failure:
322
+ return Failure(kind, CONNECTOR_ID, operation, message)
323
+
324
+
325
+ def _branch(
326
+ operation: str, branch: str, value: Output, kind: FailureKind, message: str
327
+ ) -> QueryAttempt[Output]:
328
+ return QueryAttempt.branch_result(branch, value, Receipt(), _failure(operation, kind, message))
329
+
330
+
331
+ def _decode_message_page(document: dict[str, Any]) -> MessagePage:
332
+ messages = _field(document, "Messages")
333
+ if not isinstance(messages, list):
334
+ raise ValueError
335
+ total = _field(document, "TotalCount")
336
+ if total is None:
337
+ total = 0
338
+ if type(total) is not int:
339
+ raise ValueError
340
+ return MessagePage(total, tuple(_decode_message(message) for message in messages))
341
+
342
+
343
+ def _decode_required_message(document: dict[str, Any]) -> MessageDetails:
344
+ message = _decode_message(document)
345
+ if not message.message_id:
346
+ raise ValueError
347
+ return message
348
+
349
+
350
+ def _decode_message(document: Any) -> MessageDetails:
351
+ if not isinstance(document, dict):
352
+ raise ValueError
353
+ return MessageDetails(
354
+ _string(document, "MessageID"), _string(document, "Subject"), _string(document, "Status")
355
+ )
356
+
357
+
358
+ def _field(document: dict[str, Any], name: str) -> Any:
359
+ """Match Go's case-insensitive JSON field lookup, preferring an exact key."""
360
+ if name in document:
361
+ return document[name]
362
+ folded = name.casefold()
363
+ for key, value in document.items():
364
+ if key.casefold() == folded:
365
+ return value
366
+ return None
367
+
368
+
369
+ def _string(document: dict[str, Any], name: str) -> str:
370
+ value = _field(document, name)
371
+ if value is None:
372
+ return ""
373
+ if not isinstance(value, str):
374
+ raise ValueError
375
+ return value
@@ -0,0 +1,135 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Postmark connection and project credential binding."""
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from dex_connectors.sdk import Call, ConnectionRef, Query, SecretString
9
+ from dex_connectors.sdk.projectconfig import ConnectionKey, LoadedProject
10
+
11
+ from .client import (
12
+ CONNECTOR_ID,
13
+ GET_MESSAGE_DETAILS_DEFINITION,
14
+ LIST_OUTBOUND_MESSAGES_DEFINITION,
15
+ Client,
16
+ Config,
17
+ Credentials,
18
+ )
19
+ from .models import GetMessageDetailsInput, ListOutboundMessagesInput, MessageDetails, MessagePage
20
+
21
+
22
+ class Connection:
23
+ """Nonserializable Postmark operation bindings for a named project connection."""
24
+
25
+ __slots__ = ("_list_outbound_messages", "_get_message_details", "_client", "reference")
26
+
27
+ def __init__(
28
+ self,
29
+ reference: ConnectionRef,
30
+ list_outbound_messages: Query[ListOutboundMessagesInput, MessagePage],
31
+ get_message_details: Query[GetMessageDetailsInput, MessageDetails],
32
+ client: Client | None = None,
33
+ ) -> None:
34
+ reference.validate()
35
+ if (
36
+ reference.provider != CONNECTOR_ID
37
+ or list_outbound_messages is None
38
+ or get_message_details is None
39
+ ):
40
+ raise ValueError("Postmark connection provider and operations are required")
41
+ if (
42
+ list_outbound_messages.definition().operation != LIST_OUTBOUND_MESSAGES_DEFINITION.operation
43
+ or get_message_details.definition().operation != GET_MESSAGE_DETAILS_DEFINITION.operation
44
+ ):
45
+ raise ValueError("Postmark connection operation identity differs")
46
+ self.reference = reference
47
+ self._list_outbound_messages = list_outbound_messages
48
+ self._get_message_details = get_message_details
49
+ self._client = client
50
+
51
+ def list_outbound_messages(self) -> Query[ListOutboundMessagesInput, MessagePage]:
52
+ """Return the outbound message search Query used by an application Step."""
53
+ return self._list_outbound_messages
54
+
55
+ def get_message_details(self) -> Query[GetMessageDetailsInput, MessageDetails]:
56
+ """Return the message details Query used by an application Step."""
57
+ return self._get_message_details
58
+
59
+ async def aclose(self) -> None:
60
+ """Close the owned provider transport after Worker shutdown."""
61
+ if self._client is not None:
62
+ await self._client.aclose()
63
+
64
+ def __repr__(self) -> str:
65
+ """Keep provider transport and credentials out of logs."""
66
+ return "postmark.Connection([REDACTED])"
67
+
68
+ def __reduce__(self) -> tuple[object, ...]:
69
+ """Prevent a live connection from entering durable Flow state."""
70
+ raise TypeError("Postmark connections cannot be serialized")
71
+
72
+
73
+ def new_connection(client: Client, reference: ConnectionRef) -> Connection:
74
+ """Bind a client to an explicit logical connection reference."""
75
+ if client is None:
76
+ raise ValueError("Postmark client is required")
77
+ return Connection(reference, client.list_outbound_messages(), client.get_message_details(), client)
78
+
79
+
80
+ def new_connection_with_operations(
81
+ list_outbound_messages: Query[ListOutboundMessagesInput, MessagePage],
82
+ get_message_details: Query[GetMessageDetailsInput, MessageDetails],
83
+ reference: ConnectionRef,
84
+ ) -> Connection:
85
+ """Bind mock operations to the same connection identity as the real provider."""
86
+ return Connection(reference, list_outbound_messages, get_message_details)
87
+
88
+
89
+ class _ProjectCredentialSource:
90
+ def __init__(self, project: LoadedProject, key: ConnectionKey) -> None:
91
+ self.project = project
92
+ self.key = key
93
+
94
+ async def resolve(self, call: Call) -> Credentials:
95
+ material = await self.project.resolve_credential(self.key, str(call.id))
96
+ value = material.get("api_key")
97
+ if not isinstance(value, str) or not value:
98
+ raise ValueError("Postmark credential api_key is required")
99
+ return Credentials(SecretString(value))
100
+
101
+
102
+ def new_project_connection(
103
+ project: LoadedProject, connection_name: str, http_client: httpx.AsyncClient | None = None
104
+ ) -> Connection:
105
+ """Open declared ordinary settings and resolve the current private key per call."""
106
+ if project is None:
107
+ raise ValueError("Postmark connection requires loaded project configuration")
108
+ reference = ConnectionRef(CONNECTOR_ID, connection_name)
109
+ reference.validate()
110
+ key = ConnectionKey(CONNECTOR_ID, connection_name)
111
+ matching = [
112
+ entry
113
+ for entry in project.configuration.connections
114
+ if entry.connector_id == CONNECTOR_ID and entry.connection_name == connection_name
115
+ ]
116
+ if len(matching) != 1:
117
+ raise ValueError("Postmark project connection is missing or duplicated")
118
+ if matching[0].provider != CONNECTOR_ID:
119
+ raise ValueError("Postmark project connection provider differs")
120
+ config = _decode_config(matching[0].configuration)
121
+ client = Client(config, _ProjectCredentialSource(project, key), http_client)
122
+ return new_connection(client, reference)
123
+
124
+
125
+ def _decode_config(value: dict[str, Any]) -> Config:
126
+ if set(value) - {"baseUrl", "timeout", "maxResponseBytes"}:
127
+ raise ValueError("Postmark configuration has unknown fields")
128
+ base_url = value.get("baseUrl", "")
129
+ timeout = value.get("timeout", 0)
130
+ max_response_bytes = value.get("maxResponseBytes", 0)
131
+ if not isinstance(base_url, str):
132
+ raise ValueError("Postmark configuration baseUrl must be a string")
133
+ if type(timeout) is not int or type(max_response_bytes) is not int:
134
+ raise ValueError("Postmark configuration timeout and maxResponseBytes must be integers")
135
+ return Config(base_url, timeout / 1_000_000_000, max_response_bytes)
@@ -0,0 +1,82 @@
1
+ {
2
+ "connectorId": "postmark",
3
+ "releaseTag": "connectors/postmark/go/v0.2.0",
4
+ "package": {
5
+ "ecosystem": "pypi",
6
+ "name": "dex-connector-postmark",
7
+ "version": "0.1.0"
8
+ },
9
+ "factories": [
10
+ {
11
+ "name": "new_list_outbound_messages_step",
12
+ "kind": "query",
13
+ "operationId": "listOutboundMessages",
14
+ "fields": {
15
+ "stepType": "step_type",
16
+ "annotations": "annotations",
17
+ "connectionName": "connection_name",
18
+ "resultAttribute": "result_attribute"
19
+ },
20
+ "branches": [
21
+ {
22
+ "id": "listed",
23
+ "field": "listed",
24
+ "optional": false
25
+ },
26
+ {
27
+ "id": "providerRejected",
28
+ "field": "provider_rejected",
29
+ "optional": true
30
+ },
31
+ {
32
+ "id": "invalidResponse",
33
+ "field": "invalid_response",
34
+ "optional": true
35
+ },
36
+ {
37
+ "id": "defect",
38
+ "field": "defect",
39
+ "optional": true
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "name": "new_get_message_details_step",
45
+ "kind": "query",
46
+ "operationId": "getMessageDetails",
47
+ "fields": {
48
+ "stepType": "step_type",
49
+ "annotations": "annotations",
50
+ "connectionName": "connection_name",
51
+ "resultAttribute": "result_attribute"
52
+ },
53
+ "branches": [
54
+ {
55
+ "id": "found",
56
+ "field": "found",
57
+ "optional": false
58
+ },
59
+ {
60
+ "id": "notFound",
61
+ "field": "not_found",
62
+ "optional": true
63
+ },
64
+ {
65
+ "id": "providerRejected",
66
+ "field": "provider_rejected",
67
+ "optional": true
68
+ },
69
+ {
70
+ "id": "invalidResponse",
71
+ "field": "invalid_response",
72
+ "optional": true
73
+ },
74
+ {
75
+ "id": "defect",
76
+ "field": "defect",
77
+ "optional": true
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Typed Postmark outbound message searches and details."""
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ListOutboundMessagesInput:
10
+ """One page of outbound messages; count zero means 50, and count plus offset is at most 10,000."""
11
+
12
+ count: int = 0
13
+ offset: int = 0
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class GetMessageDetailsInput:
18
+ """Postmark outbound message ID."""
19
+
20
+ message_id: str = field(default="", metadata={"dex_json_name": "messageId"})
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class MessageDetails:
25
+ """Outbound message summary with Postmark's delivery status text."""
26
+
27
+ message_id: str = field(default="", metadata={"dex_json_name": "MessageID"})
28
+ subject: str = field(default="", metadata={"dex_json_name": "Subject"})
29
+ status: str = field(default="", metadata={"dex_json_name": "Status"})
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class MessagePage:
34
+ """One page of outbound messages and Postmark's total match count."""
35
+
36
+ total_count: int = field(default=0, metadata={"dex_json_name": "TotalCount"})
37
+ messages: tuple[MessageDetails, ...] = field(default=(), metadata={"dex_json_name": "Messages"})
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Manifest-backed Postmark scripts for consuming Flow tests."""
4
+
5
+ from dex_connectors.sdk import ConnectionRef, Failure
6
+ from dex_connectors.sdk.connectormock import Case, Query
7
+
8
+ from ..client import (
9
+ CONNECTOR_ID,
10
+ FOUND,
11
+ GET_MESSAGE_DETAILS_DEFINITION,
12
+ LIST_OUTBOUND_MESSAGES_DEFINITION,
13
+ LISTED,
14
+ NOT_FOUND,
15
+ PROVIDER_REJECTED,
16
+ )
17
+ from ..connection import Connection, new_connection_with_operations
18
+ from ..models import GetMessageDetailsInput, ListOutboundMessagesInput, MessageDetails, MessagePage
19
+
20
+ _DEFAULT_MESSAGE = MessageDetails("0ac29aee-e1cd-480d-b08d-4f48548ff48d", "Welcome", "Sent")
21
+
22
+
23
+ def list_outbound_messages_listed(value: MessagePage, failure: Failure | None = None) -> Case[MessagePage]:
24
+ """Script the successful listed route."""
25
+ return Case.branch_result(LISTED, value, failure)
26
+
27
+
28
+ def list_outbound_messages_provider_rejected(
29
+ value: MessagePage, failure: Failure | None = None
30
+ ) -> Case[MessagePage]:
31
+ """Script a definite provider rejection of the search."""
32
+ return Case.branch_result(PROVIDER_REJECTED, value, failure)
33
+
34
+
35
+ def list_outbound_messages_default() -> Case[MessagePage]:
36
+ """Return the manifest's listOutboundMessagesDefault answer."""
37
+ return list_outbound_messages_listed(MessagePage(1, (_DEFAULT_MESSAGE,)))
38
+
39
+
40
+ def get_message_details_found(value: MessageDetails, failure: Failure | None = None) -> Case[MessageDetails]:
41
+ """Script the successful found route."""
42
+ return Case.branch_result(FOUND, value, failure)
43
+
44
+
45
+ def get_message_details_not_found(
46
+ value: MessageDetails, failure: Failure | None = None
47
+ ) -> Case[MessageDetails]:
48
+ """Script a missing outbound message."""
49
+ return Case.branch_result(NOT_FOUND, value, failure)
50
+
51
+
52
+ def get_message_details_default() -> Case[MessageDetails]:
53
+ """Return the manifest's getMessageDetailsDefault answer."""
54
+ return get_message_details_found(_DEFAULT_MESSAGE)
55
+
56
+
57
+ class Mock:
58
+ """Script Postmark operations, defaulting to their manifest answers."""
59
+
60
+ def __init__(self, connection_name: str = "postmark") -> None:
61
+ self.list_outbound_messages_script: Query[ListOutboundMessagesInput, MessagePage] = Query(
62
+ LIST_OUTBOUND_MESSAGES_DEFINITION
63
+ )
64
+ self.list_outbound_messages_script.default(list_outbound_messages_default())
65
+ self.get_message_details_script: Query[GetMessageDetailsInput, MessageDetails] = Query(
66
+ GET_MESSAGE_DETAILS_DEFINITION
67
+ )
68
+ self.get_message_details_script.default(get_message_details_default())
69
+ self._connection = new_connection_with_operations(
70
+ self.list_outbound_messages_script,
71
+ self.get_message_details_script,
72
+ ConnectionRef(CONNECTOR_ID, connection_name),
73
+ )
74
+
75
+ def connection(self) -> Connection:
76
+ """Return this mock's named connection for Step registration."""
77
+ return self._connection
78
+
79
+
80
+ __all__ = [
81
+ "Mock",
82
+ "list_outbound_messages_listed",
83
+ "list_outbound_messages_provider_rejected",
84
+ "list_outbound_messages_default",
85
+ "get_message_details_found",
86
+ "get_message_details_not_found",
87
+ "get_message_details_default",
88
+ ]
File without changes
@@ -0,0 +1,111 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Operation-specific Dex Step factories for Postmark outbound message operations."""
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import TypeVar
7
+
8
+ import dex
9
+ from dex_connectors.sdk import QueryResult, QueryStep, QueryStepConfig, StepAnnotations
10
+
11
+ from .connection import Connection
12
+ from .models import GetMessageDetailsInput, ListOutboundMessagesInput, MessageDetails, MessagePage
13
+
14
+ StepInput = TypeVar("StepInput")
15
+ BranchTarget = TypeVar("BranchTarget")
16
+
17
+
18
+ def new_list_outbound_messages_step(
19
+ *,
20
+ step_type: str,
21
+ step_input_type: type[StepInput],
22
+ annotations: StepAnnotations | Mapping[str, str],
23
+ connection: Connection,
24
+ connection_name: str,
25
+ map_to_operation_input: Callable[[StepInput], ListOutboundMessagesInput],
26
+ listed: dex.StepTarget[QueryResult[MessagePage]],
27
+ not_listed: dex.StepTarget[QueryResult[MessagePage]] | None = None,
28
+ provider_rejected: dex.StepTarget[QueryResult[MessagePage]] | None = None,
29
+ invalid_response: dex.StepTarget[QueryResult[MessagePage]] | None = None,
30
+ defect: dex.StepTarget[QueryResult[MessagePage]] | None = None,
31
+ result_attribute: dex.Attribute[QueryResult[MessagePage]] | None = None,
32
+ step_options_override: dex.StepOptions | None = None,
33
+ ) -> QueryStep[StepInput, ListOutboundMessagesInput, MessagePage]:
34
+ """Create the typed outbound message search Step; unwired optional outcomes fail the Flow."""
35
+ _require_connection(connection, connection_name)
36
+ return QueryStep(
37
+ QueryStepConfig(
38
+ step_type=step_type,
39
+ step_input_type=step_input_type,
40
+ annotations=_step_annotations(annotations),
41
+ operation=connection.list_outbound_messages(),
42
+ connection=connection.reference,
43
+ map_to_operation_input=map_to_operation_input,
44
+ branches=_branches(
45
+ ("listed", listed),
46
+ ("providerRejected", provider_rejected),
47
+ ("invalidResponse", invalid_response),
48
+ ("defect", defect),
49
+ ),
50
+ result_attribute=result_attribute,
51
+ step_options_override=step_options_override,
52
+ )
53
+ )
54
+
55
+
56
+ def new_get_message_details_step(
57
+ *,
58
+ step_type: str,
59
+ step_input_type: type[StepInput],
60
+ annotations: StepAnnotations | Mapping[str, str],
61
+ connection: Connection,
62
+ connection_name: str,
63
+ map_to_operation_input: Callable[[StepInput], GetMessageDetailsInput],
64
+ found: dex.StepTarget[QueryResult[MessageDetails]],
65
+ not_found: dex.StepTarget[QueryResult[MessageDetails]] | None = None,
66
+ provider_rejected: dex.StepTarget[QueryResult[MessageDetails]] | None = None,
67
+ invalid_response: dex.StepTarget[QueryResult[MessageDetails]] | None = None,
68
+ defect: dex.StepTarget[QueryResult[MessageDetails]] | None = None,
69
+ result_attribute: dex.Attribute[QueryResult[MessageDetails]] | None = None,
70
+ step_options_override: dex.StepOptions | None = None,
71
+ ) -> QueryStep[StepInput, GetMessageDetailsInput, MessageDetails]:
72
+ """Create the typed message details Step; unwired optional outcomes fail the Flow."""
73
+ _require_connection(connection, connection_name)
74
+ return QueryStep(
75
+ QueryStepConfig(
76
+ step_type=step_type,
77
+ step_input_type=step_input_type,
78
+ annotations=_step_annotations(annotations),
79
+ operation=connection.get_message_details(),
80
+ connection=connection.reference,
81
+ map_to_operation_input=map_to_operation_input,
82
+ branches=_branches(
83
+ ("found", found),
84
+ ("notFound", not_found),
85
+ ("providerRejected", provider_rejected),
86
+ ("invalidResponse", invalid_response),
87
+ ("defect", defect),
88
+ ),
89
+ result_attribute=result_attribute,
90
+ step_options_override=step_options_override,
91
+ )
92
+ )
93
+
94
+
95
+ def _require_connection(connection: Connection, connection_name: str) -> None:
96
+ if connection is None or connection.reference.name != connection_name:
97
+ raise ValueError("Postmark Step connection name must match its connection reference")
98
+
99
+
100
+ def _step_annotations(annotations: StepAnnotations | Mapping[str, str]) -> StepAnnotations:
101
+ if isinstance(annotations, StepAnnotations):
102
+ return annotations
103
+ if not isinstance(annotations, Mapping) or set(annotations) != {"groupId", "groupLabel", "explanation"}:
104
+ raise ValueError("Postmark Step annotations require groupId, groupLabel, and explanation")
105
+ if any(not isinstance(value, str) for value in annotations.values()):
106
+ raise ValueError("Postmark Step annotations must be strings")
107
+ return StepAnnotations(annotations["groupId"], annotations["groupLabel"], annotations["explanation"])
108
+
109
+
110
+ def _branches(*targets: tuple[str, BranchTarget | None]) -> dict[str, BranchTarget]:
111
+ return {branch: target for branch, target in targets if target is not None}