feat(agent-email): playground mailbox connectors + live send#1829
Conversation
…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.
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.
|
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 🔍 Technical detailsIssues🟢 Unused module logger ( 🟢 Duplicated 🟢 Empty-scope grant edge case ( Verified correct
Strengths
|
…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:_  _Connected — a **Disconnect** button on each account; **Send** picks the mailbox from a dropdown and is ready:_  ## 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>
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 connectorsuse), 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
include_in_schema=False):GET /v1/email/connectors(status),POST …/{provider}/configure(start OAuth),POST …/{provider}/complete(wait, then grant the mailbox toinstalled:emailso send resolves it),DELETE …/{provider}(full disconnect — clears tokens and per-agent grants, so a reconnect is clean).400 "can't choose". Empty + disabled when nothing is connected."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 viaget_access_token_sync(agent_id="installed:email"), which is grant-gated (the #1592 orphan failure mode).…/completegrants to exactly that id.Evidence
UI — captured from the live sidecar (
Senditself 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:
Connected — a Disconnect button on each account; Send picks the mailbox from a dropdown and is ready:
Always-on, out of the contract (no flag, no env):
Tests: the full email unit suite passes, incl. new connector-route, gating/contract-exclusion, disconnect, and default-sentinel coverage.
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 -qpython 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/connectorsis not inpaths;/v1/email/triageis.