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.
- dex_connector_google_gmail-0.1.0/.gitignore +2 -0
- dex_connector_google_gmail-0.1.0/PKG-INFO +94 -0
- dex_connector_google_gmail-0.1.0/README.md +83 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/__init__.py +3 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/flow.py +111 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/main.py +35 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/models.py +27 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/tests/test_integration.py +254 -0
- dex_connector_google_gmail-0.1.0/examples/thread_reply/worker.py +24 -0
- dex_connector_google_gmail-0.1.0/pyproject.toml +36 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/__init__.py +60 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/connection.py +56 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/definitions.py +71 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/dex-connector.json +110 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/gmailmock/__init__.py +218 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/models.py +192 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/operations.py +239 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/project.py +60 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/project_credentials.py +78 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/provider.py +479 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/py.typed +2 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/refresh.py +138 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/send.py +671 -0
- dex_connector_google_gmail-0.1.0/src/dex_connectors/google_gmail/steps.py +168 -0
- dex_connector_google_gmail-0.1.0/tests/test_config.py +49 -0
- dex_connector_google_gmail-0.1.0/tests/test_dex_codec.py +68 -0
- dex_connector_google_gmail-0.1.0/tests/test_fenced_send.py +154 -0
- dex_connector_google_gmail-0.1.0/tests/test_get_message.py +249 -0
- dex_connector_google_gmail-0.1.0/tests/test_manifest_drift.py +179 -0
- dex_connector_google_gmail-0.1.0/tests/test_project.py +188 -0
- dex_connector_google_gmail-0.1.0/tests/test_project_credentials.py +77 -0
- dex_connector_google_gmail-0.1.0/tests/test_provider_and_mocks.py +160 -0
- dex_connector_google_gmail-0.1.0/tests/test_refresh.py +182 -0
- dex_connector_google_gmail-0.1.0/tests/test_send.py +619 -0
- dex_connector_google_gmail-0.1.0/tests/test_steps.py +111 -0
- dex_connector_google_gmail-0.1.0/uv.lock +932 -0
|
@@ -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,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()
|