Skip to content

feat(agent-email): playground mailbox connectors + live send#1829

Merged
itomek merged 9 commits into
mainfrom
feat/email-playground-connectors
Jun 23, 2026
Merged

feat(agent-email): playground mailbox connectors + live send#1829
itomek merged 9 commits into
mainfrom
feat/email-playground-connectors

Conversation

@itomek

@itomek itomek commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

Before: in the email-agent playground you could triage and draft, but Send was a dead "connector required" notice — no way to connect a mailbox or send for real. After: the playground gains a collapsible Connectors panel — paste your own Google/Microsoft OAuth client credentials, connect Gmail/Outlook through GAIA's own connector framework (the same flow the Agent UI and gaia connectors use), pick the mailbox in Send, and send live.

The connector routes are mounted unconditionally by the sidecar, next to the playground page it already always serves. They reuse gaia.connectors (already compiled into the sidecar binary via the send path — no new dependency) and are excluded from the OpenAPI contract (include_in_schema=False): a playground convenience, not part of the frozen email REST contract. This survives npm/R2 packaging — the thin npm package just fetches and spawns that same binary. The connection lives in GAIA's machine-global keyring store shared by every surface, so a real consuming app can establish it via the Agent UI / CLI and the sidecar's send just reads it. Builds on the merged playground (#1796 / #1814).

What's in it

  • Connector routes (include_in_schema=False): GET /v1/email/connectors (status), POST …/{provider}/configure (start OAuth), POST …/{provider}/complete (wait, then grant the mailbox to installed:email so send resolves it), DELETE …/{provider} (full disconnect — clears tokens and per-agent grants, so a reconnect is clean).
  • Connectors panel at the top of the playground: per-provider client_id/secret → connect; a Disconnect button on each connected account (the connect form returns afterward).
  • Send gains an always-present From-mailbox dropdown: lists connected mailboxes alphabetically, selects the first, and passes the choice to draft (binding the send token) — so a 2-mailbox setup never hits the send API's 400 "can't choose". Empty + disabled when nothing is connected.
  • Account emails are shown only when every connected mailbox has one (no mixed "Gmail · addr" / bare "Outlook"); the store's "default" no-email sentinel is never surfaced.

Send needs both a connection and a grant. connected_mailbox_providers() picks the provider by connection presence, but the actual send fetches a token via get_access_token_sync(agent_id="installed:email"), which is grant-gated (the #1592 orphan failure mode). …/complete grants to exactly that id.

Evidence

UI — captured from the live sidecar (Send itself was not fired — it dispatches a real email; account emails are suppressed in the connected shot because not every connected mailbox resolved one):

Not connected — paste your own Google/Microsoft OAuth client credentials; Send is disabled:

Playground — not connected

Connected — a Disconnect button on each account; Send picks the mailbox from a dropdown and is ready:

Playground — connected

Always-on, out of the contract (no flag, no env):

$ python …/packaging/server.py --port 8135
$ curl -s -o /dev/null -w '%{http_code}' :8135/v1/email/connectors        # 200  (always mounted)
$ curl -s :8135/openapi.json | jq '.paths|keys|any(test("connectors"))'    # false (excluded from contract)
$ curl -s :8135/openapi.json | jq '.paths|has("/v1/email/triage")'         # true  (core contract intact)

Tests: the full email unit suite passes, incl. new connector-route, gating/contract-exclusion, disconnect, and default-sentinel coverage.

$ PYTHONPATH=hub/agents/python/email python -m pytest tests/unit/agents/email/ -q
417 passed

Test plan

  • PYTHONPATH=hub/agents/python/email python -m pytest tests/unit/agents/email/test_connector_routes.py tests/unit/agents/email/test_playground.py -q
  • python hub/agents/python/email/packaging/server.py --port 8131, open /v1/email/playground → connect Gmail/Outlook with your own OAuth client creds; pick the mailbox in Send → live send; Disconnect → the connect form returns.
  • curl :8131/openapi.json/v1/email/connectors is not in paths; /v1/email/triage is.

Tomasz Iniewicz added 3 commits June 23, 2026 10:02
…ound

Add /v1/email/connectors + /{provider}/configure + /{provider}/complete that
reuse GAIA's connector framework (gaia.connectors) — the same OAuth flow the
Agent UI uses. Mounted only in playground mode (--playground or
GAIA_EMAIL_PLAYGROUND=1); a default/production sidecar stays connector-free,
keeping milestone 40's 'consuming app owns the connection' boundary intact.

OAuth completes inside gaia.connectors.flow (its own loopback listener), so the
sidecar hosts no callback route. On completion the mailbox is granted to
installed:email so /v1/email/send can resolve it.
The playground page gains a collapsible Connectors section at the top that
drives the new /v1/email/connectors routes: enter your own Google/Microsoft
OAuth client creds, connect, and the Send panel goes live. On a plain
(non-playground) sidecar the routes 404 and the panel degrades to an explainer
('--playground' / GAIA_EMAIL_PLAYGROUND=1) rather than breaking. All DOM writes
stay textContent-only; fetches remain same-origin.

Tests: connector-route delegation + grant-to-installed:email, flag gating
(mounted only in playground mode), and the page's graceful degradation.
Add a Playground 'connect a mailbox + live send' note to the email README and
apply black formatting to the new connector module/tests.
@github-actions github-actions Bot added the tests Test changes label Jun 23, 2026
Tomasz Iniewicz added 4 commits June 23, 2026 10:21
Drop the --playground / GAIA_EMAIL_PLAYGROUND gate. The playground page is always
served by the sidecar, so gating only its connector routes left the page with a
dead Connectors panel — same lifecycle for both is simpler and consistent. The
connectors are compiled into the sidecar binary (not the thin npm fetch-and-spawn
wrapper) and reused from gaia.connectors, so the capability is present regardless
of any flag; always-on wins on simplicity. Routes are excluded from the OpenAPI
contract (include_in_schema=False) — a playground convenience, not part of the
frozen email REST contract. The connection lives in GAIA's machine-global store,
so a consuming app can still establish it via its own surface.
- Disconnect button on each connected provider, using the framework's full
  disconnect (clears tokens AND per-agent grants, so a reconnect can't inherit
  stale consent); the connect form returns afterward so you can re-enter creds.
- Send gains an always-present 'From mailbox' dropdown listing the connected
  mailboxes. The chosen provider is passed to draft (which binds the send token
  to it), so a 2+ mailbox setup no longer hits the send API's 'can't choose'
  400. Empty placeholder + Send disabled when nothing is connected.
… order

- Normalize the connector store DEFAULT_ACCOUNT ("default") no-email sentinel to
  absent at the /v1/email/connectors boundary, so the UI shows "connected" /
  "Microsoft · Outlook" instead of "connected · default".
- The Send "From mailbox" dropdown lists connected mailboxes sorted
  alphabetically and selects the first — no remembered or "default" selection.
If any connected mailbox lacks a resolvable account email (e.g. a Microsoft
id_token with no email claim), suppress the email on every provider rather than
mixing 'Gmail · addr' with a bare 'Outlook'. Applies to both the Connectors
panel and the Send dropdown.
@itomek itomek marked this pull request as ready for review June 23, 2026 15:27
@itomek itomek requested a review from kovtcharov-amd as a code owner June 23, 2026 15:27
@itomek itomek changed the title feat(agent-email): playground mailbox connectors + live send (flag-gated) feat(agent-email): playground mailbox connectors + live send Jun 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions — no blocking issues.

This makes the email-agent playground's Send panel actually work: a new collapsible Connectors panel lets you connect Gmail/Outlook with your own OAuth client credentials through GAIA's existing connector framework, then pick that mailbox and send live. The routes are mounted unconditionally next to the always-served playground page and kept out of the frozen OpenAPI contract, which is the right call for a dev convenience.

The integration is correct end-to-end: connecting grants the mailbox to the email agent (so send can actually fetch a token), disconnect does a full teardown (tokens and grants, so a reconnect can't inherit stale consent), and the From-mailbox dropdown passes the chosen provider into draft so a 2-mailbox setup never trips the send API's "can't choose" error. Tests cover it well, including a real-app mount/OpenAPI-exclusion check. Only minor nits below.

One thing to keep in mind (not blocking): like the rest of the sidecar playground, these routes are unauthenticated and rely on the 127.0.0.1 bind + no-CORS for protection. That's consistent with the existing send/triage routes, but configure/disconnect now mutate connector state from that same surface — fine for a local dev tool, worth a second's thought if the sidecar bind ever widens.

🔍 Technical details

Issues

🟢 Unused module logger (connector_routes.py:58) — log = logging.getLogger("gaia_agent_email.connectors") is defined but never used anywhere in the module. Either drop it or wire it into the two except handlers (a one-line log.warning(...) before re-raising would aid debugging the live OAuth path). If kept, it's harmless dead code.

🟢 Duplicated "default" sentinel in JS (playground_html.py:361) — doSend's success line re-checks st.account_email !== "default", but the server already maps the DEFAULT_ACCOUNT sentinel to null before it reaches the page (connector_routes.py:110-112). The client-side string literal is redundant defensive coupling to the store's internal sentinel value. Minor — leaving it is safe; removing the magic string keeps the sentinel knowledge server-side only.

🟢 Empty-scope grant edge case (connector_routes.py:162-164) — scopes = list(state.get("scopes") or []) then grant_agent(provider, EMAIL_AGENT_ID, scopes). If complete_authorization ever returns a state without scopes, the agent is granted with an empty scope set and send's grant check could later fail with a less-obvious error. In practice the flow always returns scopes, so this is defensive-only — no action needed unless you want to assert non-empty here.

Verified correct

  • Route delegation matches the framework contracts: configurehandler.configurestart_authorization returns {flow_id, authorization_url} (flow.py:236); completecomplete_authorization (flow.py:239) then grant_agent (grants.py:158); disconnecthandler.disconnect which calls revoke_all_grants_for (oauth_pkce.py:188), so the [Bug]: Agent UI — connected Google account can't be used by Email Triage agent (per-agent grant missing in UI, then token 401) #1592 stale-consent claim holds.
  • Call-time imports (from gaia.connectors... import ... inside each handler) are what let the tests monkeypatch the framework cleanly — correct and intentional.
  • Provider binding is real, not cosmetic: doSend passes provider into /v1/email/draft, and the draft/send API binds the confirmation token to that provider (api_routes.py:596-646, _resolve_backend_for_provider at :722). The dropdown choice genuinely drives the send backend.
  • Boundary error translation (except Exception ... raise HTTPException(...) from e) is the allowed fail-loudly pattern (system boundary → HTTP), not a silent fallback.

Strengths

  • XSS-safe by construction — the entire Connectors/Send UI is built with createElement/textContent, zero .innerHTML, and there's a test pinning that invariant (test_playground.py:68). Good discipline for injected mailbox/email strings.
  • Test qualityTestSidecarMount loads the real packaging/server.py by path and asserts the routes are both mounted (200) and excluded from the OpenAPI contract, while the core /triage path stays in-schema. That's exactly the "verify the call shape, not just that we called it" bar from CLAUDE.md.
  • Correct reuse — leans entirely on gaia.connectors rather than reimplementing OAuth, and the unconditional-mount + include_in_schema=False design keeps the frozen REST contract clean while still giving the always-served page a working panel.

@github-actions github-actions Bot added the documentation Documentation changes label Jun 23, 2026
@itomek itomek enabled auto-merge June 23, 2026 15:52
@itomek itomek added this pull request to the merge queue Jun 23, 2026
Merged via the queue into main with commit 1d720fc Jun 23, 2026
35 of 36 checks passed
@itomek itomek deleted the feat/email-playground-connectors branch June 23, 2026 15:54
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jun 23, 2026
…outes) (amd#1830)

Small follow-up to the merged amd#1829 — addresses the two actionable
(non-blocking) nits from the code review on the new connector routes:

- **Wire the unused module logger** into the `configure` / `complete` /
`grant` error handlers, so the live OAuth path is debuggable (it was
defined-but-unused).
- **Centralize the `DEFAULT_ACCOUNT` ("default") sentinel handling
server-side** — a new `_account_email` helper applied on both `GET
/v1/email/connectors` and `/complete` — and drop the redundant
client-side `!== "default"` magic string, so the store's sentinel value
stays server-side only.

The third review nit (empty-scope grant) was explicitly flagged as
needing no action; left as-is.

## Evidence

This change is **backend-only** (logger + server-side sentinel
normalization), so the playground UI is unchanged — verified below
straight from this PR's build.

**Behavioral proof** of the new normalization — `GET
/v1/email/connectors` now maps the store's `"default"` no-email sentinel
to `null`:
```
$ curl -s :8135/v1/email/connectors | jq '.providers[] | {provider, connected, account_email}'
{ "provider": "google",    "connected": true, "account_email": "…@gmail.com" }
{ "provider": "microsoft", "connected": true, "account_email": null }   # was "default" before this PR
```

**UI still works** — screenshots captured from this PR's sidecar
(md5-identical to the amd#1829 baseline, confirming the UI did not change):

_Not connected — per-provider client_id/secret connect forms; **Send**
disabled:_

![Playground — not
connected](https://raw.githubusercontent.com/amd/gaia/fix/1829-connector-review-nits/docs/assets/img/email-playground-not-connected.png)

_Connected — a **Disconnect** button on each account; **Send** picks the
mailbox from a dropdown and is ready:_

![Playground —
connected](https://raw.githubusercontent.com/amd/gaia/fix/1829-connector-review-nits/docs/assets/img/email-playground-connected.png)

## Test plan
- [ ] `PYTHONPATH=hub/agents/python/email python -m pytest
tests/unit/agents/email/test_connector_routes.py
tests/unit/agents/email/test_playground.py -q` — includes a new test
asserting `/complete` normalizes the `"default"` sentinel to `null`.

Co-authored-by: Tomasz Iniewicz <tomasz@iniewicz.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants