Metadata-Version: 2.4
Name: gmail-channel-molecule
Version: 0.3.3
Summary: Gmail inbound channel bridge for a Molecule workspace (poll-mode A2A relay, stdlib-only, per-workspace OAuth).
Author: Molecule AI
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: molecules-workspace-runtime>=0.3.70
Requires-Dist: qrcode>=7.4
Requires-Dist: Pillow>=10

# 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.

```json
[
  {"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 | 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 adding
  `gmail.send`.
- The daemon runs from **delivered source** (no published wheel required). The
  `connect` onboarding CLI is `pip install`ed on demand; the wheel is published to
  the org Gitea PyPI registry by `auto-release`→`publish` (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.
