dex-connector-google-gmail 0.1.0__tar.gz

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.
Files changed (36) hide show
  1. dex_connector_google_gmail-0.1.0/.gitignore +2 -0
  2. dex_connector_google_gmail-0.1.0/PKG-INFO +94 -0
  3. dex_connector_google_gmail-0.1.0/README.md +83 -0
  4. dex_connector_google_gmail-0.1.0/examples/thread_reply/__init__.py +3 -0
  5. dex_connector_google_gmail-0.1.0/examples/thread_reply/flow.py +111 -0
  6. dex_connector_google_gmail-0.1.0/examples/thread_reply/main.py +35 -0
  7. dex_connector_google_gmail-0.1.0/examples/thread_reply/models.py +27 -0
  8. dex_connector_google_gmail-0.1.0/examples/thread_reply/tests/test_integration.py +254 -0
  9. dex_connector_google_gmail-0.1.0/examples/thread_reply/worker.py +24 -0
  10. dex_connector_google_gmail-0.1.0/pyproject.toml +36 -0
  11. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/__init__.py +60 -0
  12. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/connection.py +56 -0
  13. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/definitions.py +71 -0
  14. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/dex-connector.json +110 -0
  15. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/gmailmock/__init__.py +218 -0
  16. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/models.py +192 -0
  17. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/operations.py +239 -0
  18. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/project.py +60 -0
  19. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/project_credentials.py +78 -0
  20. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/provider.py +479 -0
  21. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/py.typed +2 -0
  22. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/refresh.py +138 -0
  23. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/send.py +671 -0
  24. dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/steps.py +168 -0
  25. dex_connector_google_gmail-0.1.0/tests/test_config.py +49 -0
  26. dex_connector_google_gmail-0.1.0/tests/test_dex_codec.py +68 -0
  27. dex_connector_google_gmail-0.1.0/tests/test_fenced_send.py +154 -0
  28. dex_connector_google_gmail-0.1.0/tests/test_get_message.py +249 -0
  29. dex_connector_google_gmail-0.1.0/tests/test_manifest_drift.py +179 -0
  30. dex_connector_google_gmail-0.1.0/tests/test_project.py +188 -0
  31. dex_connector_google_gmail-0.1.0/tests/test_project_credentials.py +77 -0
  32. dex_connector_google_gmail-0.1.0/tests/test_provider_and_mocks.py +160 -0
  33. dex_connector_google_gmail-0.1.0/tests/test_refresh.py +182 -0
  34. dex_connector_google_gmail-0.1.0/tests/test_send.py +619 -0
  35. dex_connector_google_gmail-0.1.0/tests/test_steps.py +111 -0
  36. dex_connector_google_gmail-0.1.0/uv.lock +932 -0
@@ -0,0 +1,2 @@
1
+ /dist/dex_connector_google_gmail-0.1.0-py3-none-any.whl
2
+ /dist/dex_connector_google_gmail-0.1.0.tar.gz
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: dex-connector-google-gmail
3
+ Version: 0.1.0
4
+ Summary: Dex-native Python connector for Gmail
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
+ # Gmail connector for Python
13
+
14
+ `dex-connector-google-gmail` provides typed Gmail reads, sends, and replies for
15
+ Dex Python Flows. The package imports as `dex_connectors.google_gmail`.
16
+ Version 0.1.0 includes `getMessage`, `sendMessage`, and `replyToMessage` only;
17
+ the Go `messageReceived` and `replyReceived` Triggers are not available in Python.
18
+
19
+ ## Install
20
+
21
+ After release, install `dex-connector-google-gmail==0.1.0`. The package requires
22
+ `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`; the latter provides
23
+ Go-compatible JSON field metadata and the Flow start and attempt deadline
24
+ needed for Gmail's duplicate-send fence.
25
+
26
+ ## Configure a connection
27
+
28
+ Use one static connection name in the Flow and in Dex Web **Connections**.
29
+ Select Google OAuth for a personal Gmail or ordinary Workspace mailbox. In
30
+ [Google Cloud Console](https://console.cloud.google.com/apis/credentials),
31
+ enable Gmail API, configure the consent screen, register the exact Dex redirect
32
+ URI on a Web application, then grant `openid`, UserInfo email, Gmail read-only,
33
+ and Gmail send scopes. Dex derives the verified primary email and stores the
34
+ access and refresh tokens in encrypted project storage. Its connection provider
35
+ coordinates token refresh and persists rotated refresh tokens. A service-account
36
+ key with Workspace domain-wide delegation is the administrator-only alternative.
37
+
38
+ The Python connector uses the same manifest authorization guide and safe
39
+ defaults as the Go connector. Dex CLI/Web 1.28.0 supports Python Flow
40
+ analysis, and the package includes generated `dex-connector.json` for Dex Web
41
+ connection discovery. Dex Web authorization has not been live-verified with
42
+ this package. Do not put access tokens, refresh tokens, or service-account
43
+ keys in Flow input, results, Attributes, Streams, logs, or mock reports.
44
+
45
+ ## Run the example
46
+
47
+ [`examples/thread_reply`](examples/thread_reply/main.py) reads one received
48
+ message, replies in its existing thread, and completes with the accepted reply
49
+ IDs. Its Worker loads the `gmail-inbox` connection from the Dex Web project
50
+ configuration. Factory calls keep their connection name and annotation fields
51
+ inline so Dex CLI can read the Flow definition; the factories also accept
52
+ `StepAnnotations` for programmatic construction. Dex CLI 1.28.1 is needed for
53
+ strict FDG analysis of this example's local Steps routing into connector Steps
54
+ (tracked in Dex issue #362). The entrypoint uses the public connection and Step
55
+ APIs:
56
+
57
+ ```python
58
+ connection = new_project_connection(project, CONNECTION_NAME)
59
+ await run_thread_reply_worker(
60
+ ThreadReplyFlow(connection),
61
+ os.environ.get("DEX_BLOB_CACHE_DIRECTORY", "/tmp/dex-gmail-thread-reply-cache"),
62
+ os.environ.get("DEX_SERVER_ADDRESS", "localhost:8801"),
63
+ )
64
+ ```
65
+
66
+ Run `dexcli dev`, then from this `python/` directory run
67
+ `DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json python -m examples.thread_reply.main`.
68
+ The Flow type is `ThreadReplyFlow`; its input JSON contains `messageId` and
69
+ `replyText`. Use a real mailbox message ID and a reply body with no secrets.
70
+ The [integration test](examples/thread_reply/tests/test_integration.py) runs
71
+ the same Flow on a real Dex Worker and Client with a local fake Gmail HTTP
72
+ boundary and manifest mocks.
73
+
74
+ ## Send confirmation
75
+
76
+ Gmail does not deduplicate messages by `Message-ID`. The connector derives a
77
+ stable run-specific RFC Message-ID from the Dex Call key, run ID, and Flow start
78
+ second. Its Python Mutation Steps use the manifest's asynchronous Execute
79
+ durability and persist the single-dispatch heartbeat before a POST. On a
80
+ numbered retry it searches Sent Mail first. A prior claimed send can only be
81
+ read back; a second POST requires a persisted `notSent` or `canResend` marker.
82
+ After a possibly accepted POST loses its answer or returns 5xx, the connector
83
+ polls Sent Mail before returning Retry. A successful 2xx is already accepted
84
+ even if its response is malformed, oversized, or unreadable; a Sent Mail miss
85
+ then returns `sent` with an empty message ID. A search miss after a claimed
86
+ POST remains Retry without another dispatch; the Flow fails when its retry
87
+ budget is exhausted. A definite 429 or 503 refusal, or a transport failure
88
+ proven to precede dispatch, permits a later send. The manifest limits sends
89
+ and replies to five attempts within two minutes.
90
+
91
+ Live provider authorization and these Gmail behaviors remain unverified without
92
+ a user-controlled account: retention of the client-set Message-ID header,
93
+ `rfc822msgid` search matching, and Sent Mail indexing latency. No live provider
94
+ credentials are included in this package.
@@ -0,0 +1,83 @@
1
+ # Gmail connector for Python
2
+
3
+ `dex-connector-google-gmail` provides typed Gmail reads, sends, and replies for
4
+ Dex Python Flows. The package imports as `dex_connectors.google_gmail`.
5
+ Version 0.1.0 includes `getMessage`, `sendMessage`, and `replyToMessage` only;
6
+ the Go `messageReceived` and `replyReceived` Triggers are not available in Python.
7
+
8
+ ## Install
9
+
10
+ After release, install `dex-connector-google-gmail==0.1.0`. The package requires
11
+ `dex-connectors-sdk==0.1.0` and `dex-python-sdk>=1.28.0`; the latter provides
12
+ Go-compatible JSON field metadata and the Flow start and attempt deadline
13
+ needed for Gmail's duplicate-send fence.
14
+
15
+ ## Configure a connection
16
+
17
+ Use one static connection name in the Flow and in Dex Web **Connections**.
18
+ Select Google OAuth for a personal Gmail or ordinary Workspace mailbox. In
19
+ [Google Cloud Console](https://console.cloud.google.com/apis/credentials),
20
+ enable Gmail API, configure the consent screen, register the exact Dex redirect
21
+ URI on a Web application, then grant `openid`, UserInfo email, Gmail read-only,
22
+ and Gmail send scopes. Dex derives the verified primary email and stores the
23
+ access and refresh tokens in encrypted project storage. Its connection provider
24
+ coordinates token refresh and persists rotated refresh tokens. A service-account
25
+ key with Workspace domain-wide delegation is the administrator-only alternative.
26
+
27
+ The Python connector uses the same manifest authorization guide and safe
28
+ defaults as the Go connector. Dex CLI/Web 1.28.0 supports Python Flow
29
+ analysis, and the package includes generated `dex-connector.json` for Dex Web
30
+ connection discovery. Dex Web authorization has not been live-verified with
31
+ this package. Do not put access tokens, refresh tokens, or service-account
32
+ keys in Flow input, results, Attributes, Streams, logs, or mock reports.
33
+
34
+ ## Run the example
35
+
36
+ [`examples/thread_reply`](examples/thread_reply/main.py) reads one received
37
+ message, replies in its existing thread, and completes with the accepted reply
38
+ IDs. Its Worker loads the `gmail-inbox` connection from the Dex Web project
39
+ configuration. Factory calls keep their connection name and annotation fields
40
+ inline so Dex CLI can read the Flow definition; the factories also accept
41
+ `StepAnnotations` for programmatic construction. Dex CLI 1.28.1 is needed for
42
+ strict FDG analysis of this example's local Steps routing into connector Steps
43
+ (tracked in Dex issue #362). The entrypoint uses the public connection and Step
44
+ APIs:
45
+
46
+ ```python
47
+ connection = new_project_connection(project, CONNECTION_NAME)
48
+ await run_thread_reply_worker(
49
+ ThreadReplyFlow(connection),
50
+ os.environ.get("DEX_BLOB_CACHE_DIRECTORY", "/tmp/dex-gmail-thread-reply-cache"),
51
+ os.environ.get("DEX_SERVER_ADDRESS", "localhost:8801"),
52
+ )
53
+ ```
54
+
55
+ Run `dexcli dev`, then from this `python/` directory run
56
+ `DEX_CONNECTOR_CONFIG_FILE=/path/to/connections.json python -m examples.thread_reply.main`.
57
+ The Flow type is `ThreadReplyFlow`; its input JSON contains `messageId` and
58
+ `replyText`. Use a real mailbox message ID and a reply body with no secrets.
59
+ The [integration test](examples/thread_reply/tests/test_integration.py) runs
60
+ the same Flow on a real Dex Worker and Client with a local fake Gmail HTTP
61
+ boundary and manifest mocks.
62
+
63
+ ## Send confirmation
64
+
65
+ Gmail does not deduplicate messages by `Message-ID`. The connector derives a
66
+ stable run-specific RFC Message-ID from the Dex Call key, run ID, and Flow start
67
+ second. Its Python Mutation Steps use the manifest's asynchronous Execute
68
+ durability and persist the single-dispatch heartbeat before a POST. On a
69
+ numbered retry it searches Sent Mail first. A prior claimed send can only be
70
+ read back; a second POST requires a persisted `notSent` or `canResend` marker.
71
+ After a possibly accepted POST loses its answer or returns 5xx, the connector
72
+ polls Sent Mail before returning Retry. A successful 2xx is already accepted
73
+ even if its response is malformed, oversized, or unreadable; a Sent Mail miss
74
+ then returns `sent` with an empty message ID. A search miss after a claimed
75
+ POST remains Retry without another dispatch; the Flow fails when its retry
76
+ budget is exhausted. A definite 429 or 503 refusal, or a transport failure
77
+ proven to precede dispatch, permits a later send. The manifest limits sends
78
+ and replies to five attempts within two minutes.
79
+
80
+ Live provider authorization and these Gmail behaviors remain unverified without
81
+ a user-controlled account: retention of the client-set Message-ID header,
82
+ `rfc822msgid` search matching, and Sent Mail indexing latency. No live provider
83
+ credentials are included in this package.
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Gmail message read-and-reply example Flow."""
@@ -0,0 +1,111 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Read one Gmail message and reply in its existing thread with Dex Steps."""
4
+
5
+ import dex
6
+ from dex_connectors.sdk import MutationResult, QueryResult, QueryStep
7
+
8
+ from dex_connectors.google_gmail import (
9
+ Connection,
10
+ GetMessageInput,
11
+ Message,
12
+ ReplyToMessageInput,
13
+ SendMessageOutput,
14
+ new_get_message_step,
15
+ new_reply_to_message_step,
16
+ )
17
+
18
+ from .models import CONNECTION_NAME, ThreadReplyInput, ThreadReplyOutput
19
+
20
+ _REPLY_TEXT = dex.Attribute("gmail-thread-reply-text", str)
21
+
22
+
23
+ # dex:group group-id:gmail group-label:"Gmail"
24
+ # dex:explanation text:"Complete after Gmail confirms the threaded reply was sent."
25
+ class ReplySent(dex.Step[MutationResult[SendMessageOutput]]):
26
+ """Complete after Gmail definitely accepts the threaded reply."""
27
+
28
+ async def execute( # type: ignore[override]
29
+ self, context: dex.AsyncContext, input: MutationResult[SendMessageOutput]
30
+ ) -> dex.StepDecision:
31
+ """Return provider reply IDs without credentials or message bodies."""
32
+ return dex.graceful_complete(
33
+ ThreadReplyOutput(input.value.thread_id, input.value.message_id)
34
+ )
35
+
36
+
37
+ # dex:group group-id:gmail group-label:"Gmail"
38
+ # dex:explanation text:"Route a successful source message read to the threaded reply."
39
+ class MessageRead(dex.Step[QueryResult[Message]]):
40
+ """Send the reply after a successful source-message read."""
41
+
42
+ async def execute( # type: ignore[override]
43
+ self, context: dex.AsyncContext, input: QueryResult[Message]
44
+ ) -> dex.StepDecision:
45
+ """Reuse the source ID and the reply text stored at Flow admission."""
46
+ return dex.go_to(
47
+ dex.StepRef("ReplyToReceivedEmail"),
48
+ ReplyToMessageInput(input.value.message_id, _REPLY_TEXT.get(context)),
49
+ )
50
+
51
+
52
+ # dex:group group-id:gmail group-label:"Gmail"
53
+ # dex:explanation text:"Validate the request and retain reply text before reading Gmail."
54
+ class PrepareReply(dex.Step[ThreadReplyInput]):
55
+ """Persist the reply text before the Gmail query Step starts."""
56
+
57
+ async def execute( # type: ignore[override]
58
+ self, context: dex.AsyncContext, input: ThreadReplyInput
59
+ ) -> dex.StepDecision:
60
+ """Reject blank input and carry the source ID into getMessage."""
61
+ if not input.message_id.strip() or not input.reply_text.strip():
62
+ return dex.force_fail("Gmail message ID and reply text are required")
63
+ _REPLY_TEXT.set(context, input.reply_text)
64
+ return dex.go_to(dex.StepRef("ReadReceivedEmail"), input)
65
+
66
+
67
+ class ThreadReplyFlow(dex.Flow[ThreadReplyInput]):
68
+ """Demonstrate getMessage and replyToMessage with one static Gmail connection."""
69
+
70
+ def __init__(self, connection: Connection) -> None:
71
+ if connection.reference.name != CONNECTION_NAME:
72
+ raise ValueError("Gmail Flow requires the static gmail-inbox connection")
73
+ self.reply_sent = ReplySent()
74
+ self.reply = new_reply_to_message_step(
75
+ step_type="ReplyToReceivedEmail",
76
+ step_input_type=ReplyToMessageInput,
77
+ annotations={
78
+ "groupId": "gmail",
79
+ "groupLabel": "Gmail",
80
+ "explanation": "Reply in the received message's thread.",
81
+ },
82
+ connection=connection,
83
+ connection_name="gmail-inbox",
84
+ map_to_operation_input=lambda value: value,
85
+ sent=self.reply_sent,
86
+ )
87
+ self.message_read = MessageRead()
88
+ self.read: QueryStep[ThreadReplyInput, GetMessageInput, Message] = new_get_message_step(
89
+ step_type="ReadReceivedEmail",
90
+ step_input_type=ThreadReplyInput,
91
+ annotations={
92
+ "groupId": "gmail",
93
+ "groupLabel": "Gmail",
94
+ "explanation": "Read the source Gmail message.",
95
+ },
96
+ connection=connection,
97
+ connection_name="gmail-inbox",
98
+ map_to_operation_input=lambda value: GetMessageInput(value.message_id),
99
+ read=self.message_read,
100
+ )
101
+ self.prepare = PrepareReply()
102
+
103
+ def get_steps(self) -> dex.StepList[ThreadReplyInput]:
104
+ """Register each Step instance once under its stable Dex identity."""
105
+ return dex.StepList.start_step(self.prepare).other_steps(
106
+ self.read, self.message_read, self.reply, self.reply_sent
107
+ )
108
+
109
+ def get_persistence_schema(self) -> dex.PersistenceSchema:
110
+ """Persist only the non-secret reply text required after the read Step."""
111
+ return dex.PersistenceSchema.of(_REPLY_TEXT)
@@ -0,0 +1,35 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Run the Gmail read-and-reply example against Dex and one named connection."""
4
+
5
+ import asyncio
6
+ import os
7
+
8
+ from dex_connectors.sdk.projectconfig import load_from_environment
9
+
10
+ from dex_connectors.google_gmail import Connection, new_project_connection
11
+
12
+ from .flow import ThreadReplyFlow
13
+ from .models import CONNECTION_NAME
14
+ from .worker import run_thread_reply_worker
15
+
16
+
17
+ async def main() -> None:
18
+ """Load Dex Web connection material and serve one Flow on a pull Worker."""
19
+ project = await load_from_environment()
20
+ connection: Connection | None = None
21
+ try:
22
+ connection = new_project_connection(project, CONNECTION_NAME)
23
+ await run_thread_reply_worker(
24
+ ThreadReplyFlow(connection),
25
+ os.environ.get("DEX_BLOB_CACHE_DIRECTORY", "/tmp/dex-gmail-thread-reply-cache"),
26
+ os.environ.get("DEX_SERVER_ADDRESS", "localhost:8801"),
27
+ )
28
+ finally:
29
+ if connection is not None:
30
+ await connection.close()
31
+ await project.close()
32
+
33
+
34
+ if __name__ == "__main__":
35
+ asyncio.run(main())
@@ -0,0 +1,27 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Typed input and output for the Gmail read-and-reply example Flow."""
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ CONNECTION_NAME = "gmail-inbox"
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ThreadReplyInput:
12
+ """Identify a received Gmail message and the plain-text reply to send."""
13
+
14
+ # Provider message identifier read before replying.
15
+ message_id: str = field(metadata={"dex_json_name": "messageId"})
16
+ # Reply body sent to the message's Reply-To or From mailbox.
17
+ reply_text: str = field(metadata={"dex_json_name": "replyText"})
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ThreadReplyOutput:
22
+ """Return the read source and accepted reply IDs after the Flow completes."""
23
+
24
+ # Gmail thread shared by source and reply.
25
+ thread_id: str = field(metadata={"dex_json_name": "threadId"})
26
+ # Gmail identifier of the accepted reply; it can be empty after an unusable accepted response.
27
+ reply_message_id: str = field(metadata={"dex_json_name": "replyMessageId"})
@@ -0,0 +1,254 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Real Dex Worker and Client coverage for the Gmail read-and-reply Flow."""
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from collections.abc import Callable
9
+ from datetime import timedelta
10
+ from pathlib import Path
11
+ from typing import TypeVar
12
+ from uuid import uuid4
13
+
14
+ import dex
15
+ import httpx
16
+ import pytest
17
+ from dex_connectors.sdk import (
18
+ ConnectionRef,
19
+ MutationResult,
20
+ SecretString,
21
+ StaticCredentialProvider,
22
+ StepAnnotations,
23
+ )
24
+
25
+ from dex_connectors.google_gmail import (
26
+ Config,
27
+ Connection,
28
+ Credentials,
29
+ GmailProvider,
30
+ SendMessageInput,
31
+ SendMessageOutput,
32
+ new_connection,
33
+ new_send_message_step,
34
+ )
35
+ from dex_connectors.google_gmail.gmailmock import Mock
36
+ from examples.thread_reply.flow import ThreadReplyFlow
37
+ from examples.thread_reply.models import CONNECTION_NAME, ThreadReplyInput, ThreadReplyOutput
38
+
39
+ FlowInput = TypeVar("FlowInput")
40
+
41
+
42
+ async def _run_flow(
43
+ connection: Connection,
44
+ input_value: ThreadReplyInput,
45
+ cache_directory: Path,
46
+ ) -> dex.FlowResult:
47
+ """Run the checked-in example Flow against a real Dex Worker and Client."""
48
+ return await _run_registered_flow(ThreadReplyFlow(connection), input_value, cache_directory)
49
+
50
+
51
+ async def _run_registered_flow(
52
+ flow: dex.Flow[FlowInput], input_value: FlowInput, cache_directory: Path
53
+ ) -> dex.FlowResult:
54
+ """Start one unique Flow and wait for its actual terminal Dex result."""
55
+ server_address = os.environ.get("DEX_SERVER_ADDRESS", "localhost:8801")
56
+ blob_cache = dex.open_blob_cache(dex.BlobCacheConfig(str(cache_directory), 64 * 1024 * 1024))
57
+ registry = dex.Registry([flow], allow_async_handlers=True)
58
+ worker = dex.AsyncWorker(registry, blob_cache, dex.WorkerOptions(server_address=server_address))
59
+ client = dex.AsyncClient(registry, blob_cache, dex.ClientOptions(server_address=server_address))
60
+ worker_task = asyncio.create_task(worker.start())
61
+ try:
62
+ await worker.wait_pull_registered(timeout=30)
63
+ flow_id = f"gmail-python-integ-{uuid4().hex}"
64
+ await client.start_flow(flow, flow_id, input_value)
65
+ return await client.wait_for_flow(flow_id, timeout=timedelta(seconds=40))
66
+ finally:
67
+ await client.close()
68
+ await worker.close()
69
+ await worker_task
70
+ blob_cache.close()
71
+
72
+
73
+ def _source_response() -> httpx.Response:
74
+ return httpx.Response(
75
+ 200,
76
+ json={
77
+ "id": "source-1",
78
+ "threadId": "thread-1",
79
+ "internalDate": "1772615700000",
80
+ "payload": {
81
+ "headers": [
82
+ {"name": "From", "value": "Ada <ada@example.com>"},
83
+ {"name": "Message-ID", "value": "<source@example.com>"},
84
+ {"name": "Subject", "value": "Question"},
85
+ ]
86
+ },
87
+ },
88
+ )
89
+
90
+
91
+ async def _provider_flow(
92
+ respond: Callable[[httpx.Request], httpx.Response],
93
+ input_value: ThreadReplyInput,
94
+ cache_directory: Path,
95
+ ) -> dex.FlowResult:
96
+ """Use a fake Gmail HTTP boundary with a real Dex Worker and Client."""
97
+ credential = Credentials(SecretString("inert-access"), "owner@example.org")
98
+ credentials = StaticCredentialProvider({ConnectionRef("google", CONNECTION_NAME): credential})
99
+ async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
100
+ provider = GmailProvider(
101
+ Config(endpoint="http://localhost/gmail/v1"), credentials, http_client=http_client
102
+ )
103
+ return await _run_flow(
104
+ new_connection(provider, ConnectionRef("google", CONNECTION_NAME)), input_value,
105
+ cache_directory,
106
+ )
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_real_dex_retries_read_then_sends_one_threaded_reply(tmp_path: Path) -> None:
111
+ """Dex retries the Query Step, then the Mutation Step confirms one reply."""
112
+ requests: list[str] = []
113
+
114
+ def respond(request: httpx.Request) -> httpx.Response:
115
+ request_kind = request.url.params.get("format", "POST") if request.method == "GET" else "POST"
116
+ requests.append(request_kind)
117
+ if request_kind == "full" and requests.count("full") == 1:
118
+ return httpx.Response(503)
119
+ if request.method == "GET":
120
+ return _source_response()
121
+ payload = json.loads(request.content)
122
+ assert payload["threadId"] == "thread-1"
123
+ return httpx.Response(200, json={"id": "reply-1", "threadId": "thread-1"})
124
+
125
+ result = await _provider_flow(
126
+ respond, ThreadReplyInput("source-1", "Processing complete."), tmp_path / "cache"
127
+ )
128
+ assert result.status == dex.FlowStatus.COMPLETED
129
+ assert result.single_output(ThreadReplyOutput) == ThreadReplyOutput("thread-1", "reply-1")
130
+ assert requests == ["full", "full", "metadata", "POST"]
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_real_dex_unwired_optional_branch_fails_with_failure(tmp_path: Path) -> None:
135
+ """A definite Gmail 404 selects notFound and fails the unwired optional route."""
136
+ result = await _provider_flow(
137
+ lambda _: httpx.Response(404),
138
+ ThreadReplyInput("missing-1", "Processing complete."),
139
+ tmp_path / "cache",
140
+ )
141
+ assert result.status == dex.FlowStatus.FAILED
142
+ assert result.error_message is not None
143
+ assert "notFound" in result.error_message
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_real_dex_manifest_mocks_run_the_same_flow(tmp_path: Path) -> None:
148
+ """Manifest default Query and Mutation cases complete through the two Step factories."""
149
+ mock = Mock(CONNECTION_NAME)
150
+ result = await _run_flow(
151
+ mock.connection, ThreadReplyInput("source-1", "Processing complete."), tmp_path / "cache"
152
+ )
153
+ assert result.status == dex.FlowStatus.COMPLETED
154
+ assert result.single_output(ThreadReplyOutput) == ThreadReplyOutput(
155
+ "18c0a1b2c3d4e5f0", "18c0a1b2c3d4e700"
156
+ )
157
+ assert len(mock.get_message.calls()) == 1
158
+ assert len(mock.reply_to_message.calls()) == 1
159
+
160
+
161
+ # dex:group group-id:gmail group-label:"Gmail"
162
+ # dex:explanation text:"Complete after Gmail confirms one new message was sent."
163
+ class SendConfirmed(dex.Step[MutationResult[SendMessageOutput]]):
164
+ """Complete after the connector proves a send was accepted."""
165
+
166
+ async def execute( # type: ignore[override]
167
+ self, context: dex.AsyncContext, input: MutationResult[SendMessageOutput]
168
+ ) -> dex.StepDecision:
169
+ """Return only Gmail's message ID."""
170
+ return dex.graceful_complete(input.value.message_id)
171
+
172
+
173
+ class SendMessageFlow(dex.Flow[SendMessageInput]):
174
+ """Exercise the third Gmail operation-specific Step factory on real Dex."""
175
+
176
+ def __init__(self, connection: Connection) -> None:
177
+ self.confirmed = SendConfirmed()
178
+ self.send = new_send_message_step(
179
+ step_type="SendOneEmail",
180
+ step_input_type=SendMessageInput,
181
+ annotations=StepAnnotations(
182
+ group_id="gmail", group_label="Gmail", explanation="Send one message after read-back."
183
+ ),
184
+ connection=connection,
185
+ connection_name=CONNECTION_NAME,
186
+ map_to_operation_input=lambda value: value,
187
+ sent=self.confirmed,
188
+ )
189
+
190
+ def get_steps(self) -> dex.StepList[SendMessageInput]:
191
+ """Register sendMessage and its happy-path completion target."""
192
+ return dex.StepList.start_step(self.send).other_steps(self.confirmed)
193
+
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_real_dex_send_retry_reads_back_before_duplicate_post(tmp_path: Path) -> None:
197
+ """An ambiguous first POST retries, then finds the sent message before another POST."""
198
+ methods: list[str] = []
199
+
200
+ def respond(request: httpx.Request) -> httpx.Response:
201
+ methods.append(request.method)
202
+ if request.method == "POST":
203
+ return httpx.Response(500)
204
+ if methods.count("GET") <= 4:
205
+ return httpx.Response(200, json={"messages": []})
206
+ return httpx.Response(200, json={"messages": [{"id": "sent-1", "threadId": "thread-1"}]})
207
+
208
+ credential = Credentials(SecretString("inert-access"), "owner@example.org")
209
+ credentials = StaticCredentialProvider({ConnectionRef("google", CONNECTION_NAME): credential})
210
+ async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
211
+ provider = GmailProvider(
212
+ Config(endpoint="http://localhost/gmail/v1"), credentials, http_client=http_client
213
+ )
214
+ flow = SendMessageFlow(new_connection(provider, ConnectionRef("google", CONNECTION_NAME)))
215
+ assert flow.send.get_step_options().execute_durability == dex.StepDurability.ASYNC
216
+ result = await _run_registered_flow(
217
+ flow,
218
+ SendMessageInput(["ada@example.com"], "Progress", "Processing complete."),
219
+ tmp_path / "cache",
220
+ )
221
+ assert result.status == dex.FlowStatus.COMPLETED, (result.error_message, methods)
222
+ assert result.single_output(str) == "sent-1"
223
+ assert methods == ["POST", "GET", "GET", "GET", "GET", "GET"]
224
+
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_real_dex_503_resends_after_sent_mail_miss(tmp_path: Path) -> None:
228
+ """A definite 503 retries with one pre-send search before the second POST."""
229
+ methods: list[str] = []
230
+
231
+ def respond(request: httpx.Request) -> httpx.Response:
232
+ methods.append(request.method)
233
+ if request.method == "GET":
234
+ return httpx.Response(200, json={"messages": []})
235
+ if methods.count("POST") == 1:
236
+ return httpx.Response(503)
237
+ return httpx.Response(200, json={"id": "sent-after-503", "threadId": "thread-1"})
238
+
239
+ credential = Credentials(SecretString("inert-access"), "owner@example.org")
240
+ credentials = StaticCredentialProvider({ConnectionRef("google", CONNECTION_NAME): credential})
241
+ async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
242
+ provider = GmailProvider(
243
+ Config(endpoint="http://localhost/gmail/v1"), credentials, http_client=http_client
244
+ )
245
+ flow = SendMessageFlow(new_connection(provider, ConnectionRef("google", CONNECTION_NAME)))
246
+ assert flow.send.get_step_options().execute_durability == dex.StepDurability.ASYNC
247
+ result = await _run_registered_flow(
248
+ flow,
249
+ SendMessageInput(["ada@example.com"], "Progress", "Processing complete."),
250
+ tmp_path / "cache",
251
+ )
252
+ assert result.status == dex.FlowStatus.COMPLETED, (result.error_message, methods)
253
+ assert result.single_output(str) == "sent-after-503"
254
+ assert methods == ["POST", "GET", "GET", "GET", "GET", "GET", "POST"]
@@ -0,0 +1,24 @@
1
+ # Copyright (c) 2026 Super Durable
2
+ # SPDX-License-Identifier: MIT
3
+ """Run the Gmail example Flow with the released async Dex Worker."""
4
+
5
+ from dex import AsyncWorker, BlobCacheConfig, Flow, Registry, WorkerOptions, open_blob_cache
6
+
7
+ from .models import ThreadReplyInput
8
+
9
+
10
+ async def run_thread_reply_worker(
11
+ flow: Flow[ThreadReplyInput], cache_directory: str, server_address: str = "localhost:8801"
12
+ ) -> None:
13
+ """Register one Flow on a pull Worker and close its local blob cache on exit."""
14
+ if flow is None or not cache_directory:
15
+ raise ValueError("thread reply Flow and blob cache directory are required")
16
+ blob_cache = open_blob_cache(BlobCacheConfig(cache_directory, 64 * 1024 * 1024))
17
+ worker = AsyncWorker(
18
+ Registry([flow], allow_async_handlers=True), blob_cache, WorkerOptions(server_address=server_address)
19
+ )
20
+ try:
21
+ await worker.start()
22
+ finally:
23
+ await worker.close()
24
+ blob_cache.close()