gmail-channel-molecule (0.1.5)
Installation
pip install --index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ --extra-index-url https://pypi.org/simple gmail-channel-moleculeAbout this package
Gmail inbound channel bridge for a Molecule workspace (poll-mode A2A relay, stdlib-only, per-workspace OAuth).
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.
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.