hongming ee3282ce98
auto-release / gate (push) Successful in 18s
ci / gmail channel — unit tests (push) Successful in 18s
auto-release / release (push) Successful in 5s
publish / publish (push) Successful in 46s
fix(multi): floor the per-account tick budget so a 2nd mailbox can't wedge intake (0.3.3) (#17)
Approved by molecule-code-reviewer on 2b54e296ae.

Live incident: molecule-core#5162 (Reno Stars).
2026-08-10 23:07:58 +00:00

gmail-channel-molecule

First-party Gmail inbound channel for Molecule workspaces. Polls a Gmail mailbox (stdlib only) and relays each new message into a workspace over A2A message/send, replay-guarded by a durable poll cursor. Credentials are per-workspace — minted via a Google device grant (gmail-connect skill) and stored as the workspace's own secrets; nothing mailbox-specific is baked in.

Modeled on plugins/lark-channel. Discoverable + install-on-demand via the molecule-core manifest.json plugin catalog (NOT auto-installed).

Layout

Path Purpose
plugin.yaml Marketplace manifest — kind: channel, contributes the gmail-bridge daemon + gmail-connect skill.
gmail_channel_molecule/bridge.py Pure decision helpers (plan_message/to_envelope) + Gmail REST + A2A relay + poll loop.
gmail_channel_molecule/daemon.py Console entry — poll/daemon modes, env-wins credential loading, connect dispatch.
gmail_channel_molecule/onboard.py Google device-grant onboarding → writes GMAIL_* workspace secrets + restart.
skills/gmail-connect/SKILL.md Agent-driven connect flow (start → status → finalize).
daemon-bootstrap.sh contributes.daemons entry — installs delivered source, execs the daemon.

Connect

See skills/gmail-connect/SKILL.md. In short: create a Google OAuth client of type "TV and Limited Input devices", export GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET, then gmail-channel-molecule connect start | status | finalize.

Multi-account (poll) — GMAIL_ACCOUNTS

One daemon can poll N mailboxes. Set the GMAIL_ACCOUNTS secret to a JSON array; the daemon polls every account each sweep with an independent per-mailbox cursor and per-account failure isolation — one bad refresh token is deferred and logged, the other mailboxes keep polling.

[
  {"user_email": "sales@example.com", "refresh_token": "1//..."},
  {"user_email": "info@example.com",  "refresh_token": "1//...", "query": "in:inbox"}
]
  • client_id / client_secret default to the shared top-level GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET (one Google app for the fleet); a per-entry override is honored for a mixed fleet.
  • query defaults to GMAIL_QUERY (default in:inbox newer_than:2d).
  • A malformed entry is skipped with a log, never crashing the daemon.
  • Back-compat: with GMAIL_ACCOUNTS unset the daemon runs the single-account GMAIL_* path unchanged (including the fail-closed mailbox-identity refusal). If both are set, the flat GMAIL_* primary is merged in ahead of the list (winning an email collision).

Onboarding N mailboxes: onboard the primary with connect (writes flat GMAIL_*), then for each additional inbox run connect start | status | addconnect add upserts the minted token into GMAIL_ACCOUNTS (dedup by address) and restarts. connect list prints the configured account emails (no secret values).

Per-mailbox health is in status.json under accounts.<email> (cursor, last error, deferred). Push delivery (users.watch() → Pub/Sub) is intentionally out of scope here — this is the poll model at fleet scale.

Push (near-real-time) — GMAIL_PUSH

Opt-in. Gmail users.watch() publishes a {emailAddress, historyId} doorbell to a Cloud Pub/Sub topic; the daemon runs an outbound-only pull loop on its own thread and, on each notification, immediately polls that mailbox instead of waiting out the interval — cutting latency to seconds. Push is strictly additive: a notification only nudges the existing poll (one relay path, one cursor writer), so it can make intake faster but never stop it — poll is the correctness floor. With GMAIL_PUSH unset the daemon is byte-identical to the poll build; any missing config piece ⇒ push stays off.

Config (all required to arm):

var meaning
GMAIL_PUSH=1 opt in
GMAIL_PUBSUB_TOPIC projects/<p>/topics/<t> — what watch() publishes to
GMAIL_PUBSUB_SUBSCRIPTION projects/<p>/subscriptions/<s> — a pull subscription
GMAIL_PUBSUB_SA_JSON_B64 base64 of a service-account key with roles/pubsub.subscriber on the subscription — Pub/Sub auth is separate from the Gmail tokens (no scope change, no re-onboarding)
GMAIL_PUBSUB_LABEL_IDS optional, default INBOX

GCP setup: enable Pub/Sub; create the topic; grant gmail-api-push@system.gserviceaccount.com roles/pubsub.publisher on the topic; create a service account + key with roles/pubsub.subscriber; create a pull subscription. Watch leases are ≤7 days and the daemon re-arms them in-process on its tick cadence (a reserved _watch key in the cursor). Credential is a dedicated SA via a pure-stdlib RS256 JWT-bearer signer — no new dependency. Rollback is instant: GMAIL_PUSH=0.

Scope (v0.1.0)

  • Inbound only (gmail.readonly). Replies-to-email would be a v2 adding gmail.send.
  • The daemon runs from delivered source (no published wheel required). The connect onboarding CLI is pip installed on demand; the wheel is published to the org Gitea PyPI registry by auto-releasepublish (a green merge to main cuts tag v<version>, which builds + uploads the wheel), so pip install gmail-channel-molecule==0.1.0 resolves.
  • Unit-tested (pure helpers + onboarding finalize contract); a live OAuth + relay verification against a real mailbox is recommended before fleet rollout.

Diagnosing a silent channel

The state dir (GMAIL_CHANNEL_STATE_DIR, else MOLECULE_PLUGIN_STATE_DIR — the platform-assigned, already-namespaced dir from the plugin-state contract — else ~/.gmail-channel) holds two files:

File Written Read it to answer
cursor.json only when mail moves where the high-water-mark is; which messages are deferred
status.json every tick, always is the loop still ticking, what did the last tick fail on, and is state durable

status.json reports the plugin-state posture. "state_durable": false means the cursor does not survive a container recreate; "degraded" then carries the operator-facing explanation and "cursor_seed_mode" says how this boot seeded the high-water-mark ("pinned-now" when durable, "bounded-lookback" when not). Read those before concluding a channel is healthy.

cursor.json alone cannot distinguish "the daemon is wedged" from "the mailbox is quiet" — both look like a frozen mtime. status.json carries tick_finished_at and last_error, so a stale beat means wedged and a fresh beat with a last_error names the failure.

Poll-loop invariants worth knowing before debugging:

  • A tick cannot run forever: GMAIL_TICK_BUDGET_SECONDS (default 300) is a hard SIGALRM deadline. urlopen's timeout= is a per-socket-operation timeout, not a deadline, so it is not by itself a bound on a tick.
  • A single message can never wedge the channel: one that Gmail won't hand over is skipped, one whose sender hasn't materialized is deferred (MAX_DEFER ticks, then promoted with a fallback sender), and one the platform refuses is retried next tick. In all three cases newer mail keeps flowing.
  • The cursor is a commit frontier: it never advances past a message that was not relayed. Messages relayed ahead of the frontier are held in _promoted until it catches up, which is what makes flowing past a stuck message safe from duplicates.

State durability, and the trade when there is none

The manifest declares contributes.state: {durability: required} (plugin-state contract, RFC molecule-ai-sdk#181). The runtime answers with a directory and an honest MOLECULE_PLUGIN_STATE_DURABLE flag; the plugin never guesses either.

When durable the first-run branch pins the high-water-mark to now() — it fires once, ever, and suppresses a one-off backlog flood.

When not durable the cursor is wiped on every container recreate. Pinning to now() then re-pins the mark on every restart, making all older mail permanently invisible — that is runtime#360 defect A, measured live at 18:53:11Z on one deploy and 20:51:52Z on the next. So the bridge instead re-seeds to a bounded lookback (the query's own newer_than: window; GMAIL_NONDURABLE_LOOKBACK_SECONDS overrides, capped at 7d).

That is a deliberate trade, and it is not free. Replay suppression in this plugin is entirely local (the cursor frontier + _promoted), and both die with the state dir. x_sync_uuid is a stable id, not a dedup guarantee: nothing on the platform reads it, and the nearest mechanism — the a2a_queue idempotency index on the JSON-RPC messageId — is partial (WHERE status IN ('queued','dispatched')), so it only collapses a re-POST of a turn still in flight. A completed turn re-enqueues freely.

So on a non-durable substrate, mail inside the lookback window can be delivered twice. That was chosen over the alternative because a re-delivered email is visible and recoverable while a silently dropped one is neither. An operator who would rather miss mail than ever double-deliver it sets GMAIL_NONDURABLE_LOOKBACK_SECONDS=0, which restores the old pin-to-now() behaviour.

The durable fix is for the provisioner to supply a real MOLECULE_PLUGIN_STATE_ROOT for the workspace; then none of the above applies.

S
Description
Gmail inbound channel plugin — polls Gmail (per-workspace OAuth) and relays new mail into a Molecule workspace over A2A. First-party generalization of the poll-mode bridge.
Readme 648 KiB
Languages
Python 99.4%
Shell 0.6%