Approved by molecule-code-reviewer on 2b54e296ae.
Live incident: molecule-core#5162 (Reno Stars).
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_secretdefault to the shared top-levelGMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET(one Google app for the fleet); a per-entry override is honored for a mixed fleet.querydefaults toGMAIL_QUERY(defaultin:inbox newer_than:2d).- A malformed entry is skipped with a log, never crashing the daemon.
- Back-compat: with
GMAIL_ACCOUNTSunset the daemon runs the single-accountGMAIL_*path unchanged (including the fail-closed mailbox-identity refusal). If both are set, the flatGMAIL_*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 | add —
connect 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 addinggmail.send. - The daemon runs from delivered source (no published wheel required). The
connectonboarding CLI ispip installed on demand; the wheel is published to the org Gitea PyPI registry byauto-release→publish(a green merge to main cuts tagv<version>, which builds + uploads the wheel), sopip install gmail-channel-molecule==0.1.0resolves. - 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'stimeout=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_DEFERticks, 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
_promoteduntil 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.