Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 86 additions & 23 deletions specs/deploy/gmail-inbound-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ Gmail mailbox ──watch──▶ Cloud Pub/Sub topic ──push sub (OIDC JWT)
Vercel Cron ──GET /api/v1/internal/queue/drain (every minute)──▶ drain N jobs:
reconcile (history.list → messages.get raw) → idempotent ingest → conversation
Vercel Cron ──GET /api/v1/internal/cron/watch-maintenance (daily)──▶ re-arm watch + sweep
Vercel Cron ──GET /api/v1/internal/cron/reconcile-sweep (every minute)──▶ enqueue reconcile per mailbox
Vercel Cron ──GET /api/v1/internal/cron/watch-maintenance (daily)──▶ re-arm watch() [push only]

Operator connect: POST /api/v1/inbound/gmail/connect (Bearer) → consentUrl
→ browser → Google consent → GET /callback → mailbox connected
Expand Down Expand Up @@ -71,13 +72,34 @@ the row commits) is what protects invariant #1.

---

## Part A — Google Cloud: OAuth app + Gmail + Pub/Sub

Do this in the Google Cloud project that will own the push topic.
## Part A — Google Cloud: OAuth app (+ optional Pub/Sub)

> **Read this before starting.** As of HT-94, only **A1 and A2** are required.
> A3 and A4 configure Gmail **push**, which is now optional: inbound mail
> arrives either by push webhook or by the bounded scheduled fetch that runs
> every minute (CHARTER.md §2, amended 2026-07-20).
>
> **Skipping A3/A4 is the recommended path for most operators.** It removes six
> setup steps — including the two that fail *silently*, the
> domain-restricted-sharing org-policy block and the missing
> `serviceAccountTokenCreator` grant — and removes the requirement that the
> Cloud project have **billing enabled**, which Pub/Sub forces and the Gmail API
> alone does not.
>
> What you give up is latency: push delivers in seconds, the sweep within 60
> seconds. For a support inbox that difference is not usually worth ten console
> steps. Push remains fully supported and can be added later without a
> reconnect — set the three env vars and redeploy.

Do this in the Google Cloud project that will own the OAuth app.

### A1. Enable the APIs
Console → *APIs & Services → Enable APIs* → enable **Gmail API** and **Cloud
Pub/Sub API**. (CLI: `gcloud services enable gmail.googleapis.com pubsub.googleapis.com`.)
Console → *APIs & Services → Enable APIs* → enable **Gmail API**.

Also enable **Cloud Pub/Sub API** *only if* you are doing the optional A3.

(CLI: `gcloud services enable gmail.googleapis.com`, adding
`pubsub.googleapis.com` only when you want push.)

### A2. The Internal OAuth app + client credentials
1. *APIs & Services → OAuth consent screen* → **Internal** user type. Fill
Expand All @@ -98,7 +120,17 @@ time): `https://www.googleapis.com/auth/gmail.readonly` +
`https://www.googleapis.com/auth/gmail.send` (gmail-connect.md §3, least
privilege).

### A3. The Pub/Sub topic + push subscription
### A3. The Pub/Sub topic + push subscription — **OPTIONAL**

> Skip this whole section (and A4) unless you specifically want sub-minute
> latency. Without it the engine ingests through the every-minute reconcile
> sweep, and `GMAIL_PUBSUB_TOPIC` / `GMAIL_PUBSUB_SUBSCRIPTION` /
> `GMAIL_PUSH_SERVICE_ACCOUNT` are all left unset.
>
> **All three or none.** Setting some but not all is rejected at boot with an
> error naming the missing ones — a half-configured push is a push you believe
> works and doesn't, which is exactly the failure this optionality exists to
> remove.
1. *Pub/Sub → Topics → Create topic*, e.g. `gmail-push`. Full name
`projects/<project>/topics/gmail-push` → `GMAIL_PUBSUB_TOPIC`.
2. **Grant Gmail permission to publish** to the topic: add principal
Expand All @@ -120,8 +152,16 @@ privilege).

> The initial `users.watch` (which points the mailbox at the topic) is armed
> automatically by the **connect flow** (Part E) — you do not call it by hand.
> When push is not configured, connect skips the arm entirely and seeds the
> baseline cursor from `getProfile()` instead; nothing else about connect
> changes.

### A4. Console/CLI gotchas hit during live provisioning (2026-07-17)
### A4. Console/CLI gotchas hit during live provisioning (2026-07-17) — **OPTIONAL, applies only to A3**

> Both gotchas below fail **silently**: the grant or the subscription looks
> created, and push simply never arrives. They are the strongest single
> argument for skipping A3 entirely — the scheduled sweep has no equivalent
> failure mode, because there is nothing to provision.

1. **Domain-restricted sharing blocks the Gmail publisher grant.** If the org
enforces `constraints/iam.allowedPolicyMemberDomains`, granting
Expand Down Expand Up @@ -179,13 +219,20 @@ privilege).
2. `PUBLIC_BASE_URL` = your production URL (e.g. `https://desk.resonantiq.app`),
matching the OAuth redirect URI (A2.3) and the Pub/Sub push endpoint (A3.4).
No trailing slash (the composition root strips one defensively either way).
3. Deploy. `vercel.json` (in the repo) declares three Vercel Cron jobs:
3. Deploy. `vercel.json` (in the repo) declares **five** Vercel Cron jobs:
- `*/1 * * * *` → `GET /api/v1/internal/queue/drain` (drain the job queue —
also delivers webhooks, : `WEBHOOK_DELIVERY_TOPIC` is handled here).
- `*/1 * * * *` → `GET /api/v1/internal/outbox/drain` (turn
`event_outbox` rows into webhook-delivery queue jobs — a SEPARATE tick
from the queue drain above; that one then actually sends them).
- `0 6 * * *` → `GET /api/v1/internal/cron/watch-maintenance` (daily renewal + sweep; UTC).
- `*/1 * * * *` → `GET /api/v1/internal/cron/snooze-wake` (HT-77: flip due
`pending`+snoozed conversations back to `active`).
- `*/1 * * * *` → `GET /api/v1/internal/cron/reconcile-sweep` (HT-94: enqueue
a reconcile job per active mailbox — **this is the inbound transport**.
Runs whether or not push is configured; with push it is a backstop, without
it, it is how mail arrives at all).
- `0 6 * * *` → `GET /api/v1/internal/cron/watch-maintenance` (daily `watch()`
renewal; UTC). Reports a skip when push is not configured.
Vercel Cron invokes these as HTTP GETs; the handlers require the
`CRON_SECRET` (Vercel sends it as a bearer via the `Authorization` header on
cron requests) and are idempotent + lease-bounded.
Expand All @@ -194,10 +241,15 @@ privilege).
> more-frequent expression *fails deployment* — so the ~1-minute delivery
> latency this design targets is a Pro-tier feature.
4. **Vercel does not retry a failed cron invocation** — a transient non-2xx is
simply retried on the *next* scheduled tick. The queue drain self-heals on
the following minute; but the **daily** watch-maintenance job would go a full
day between attempts, so **alert on its non-2xx responses** (Vercel's cron
logs, or your log drain) rather than waiting to notice a stale mailbox.
simply retried on the *next* scheduled tick. The every-minute jobs self-heal
on the following minute; but the **daily** watch-maintenance job would go a
full day between attempts, so **alert on its non-2xx responses** (Vercel's
cron logs, or your log drain) rather than waiting to notice a stale mailbox.

The reconcile sweep deserves its own alert for a different reason: it
self-heals on the next tick, but a *persistently* failing sweep on a
push-free deployment means **no mail is arriving at all**, silently. Alert on
sustained non-2xx, not on a single one.
5. **`maxDuration` must stay below the queue lease.** `vercel.json` caps the
function at **50s**, under both the 60s job lease (`DEFAULT_LEASE_MS`,
`src/providers/adapters/postgres-queue/`) and the 60s cron interval: the
Expand All @@ -221,9 +273,13 @@ function files. The cron paths above resolve through that same function.
| `HELPTHREAD_BLOB_BUCKET` | Supabase B3 | private bucket name |
| `GMAIL_OAUTH_CLIENT_ID` | Google A2 | |
| `GMAIL_OAUTH_CLIENT_SECRET` | Google A2 | secret |
| `GMAIL_PUBSUB_TOPIC` | Google A3.1 | `projects/…/topics/…` |
| `GMAIL_PUBSUB_SUBSCRIPTION` | Google A3.4 | `projects/…/subscriptions/…` |
| `GMAIL_PUSH_SERVICE_ACCOUNT` | Google A3.3 | the push SA email (JWT `email` claim) |
| `GMAIL_PUBSUB_TOPIC` | Google A3.1 | **OPTIONAL** — `projects/…/topics/…` |
| `GMAIL_PUBSUB_SUBSCRIPTION` | Google A3.4 | **OPTIONAL** — `projects/…/subscriptions/…` |
| `GMAIL_PUSH_SERVICE_ACCOUNT` | Google A3.3 | **OPTIONAL** — the push SA email (JWT `email` claim) |

> The three `GMAIL_PUBSUB*` / `GMAIL_PUSH*` vars are **all-or-nothing**. Set all
> three to enable push, or none to run on the scheduled sweep alone. Any partial
> combination fails at boot with an error naming what's missing.
| `HELPTHREAD_TOKEN_ENC_KEY` | you mint (C1) | 32-byte base64; encrypts tokens at rest |
| `HELPTHREAD_API_TOKEN` | you mint (C1) | Agent-inbox Bearer, ≥16 chars |
| `CRON_SECRET` | you mint (C1) | guards internal cron endpoints |
Expand Down Expand Up @@ -303,9 +359,10 @@ Each `alerts[]` entry is `<code>: <detail>`. The codes are stable:
| `ingest-dead-letter-growth` | An inbound delivery exhausted its retry budget in the last 24h — a message an Agent has NOT seen | `SELECT provider_message_id, last_error, attempts FROM inbound_deliveries WHERE status = 'dead-letter' ORDER BY updated_at DESC`; the raw mail is still in Gmail — reprocess after fixing the cause |
| `forged-token-burst` | ≥ threshold (default 5) stored deliveries in 24h carried reply tokens that FAILED signature verification — someone is guessing/tampering with threading tokens (threading.md §5) | Search Vercel logs for `forged_token_detected` (WARN); review `senderAddress`/`conversationId` across events. The mail itself threaded safely (a forged token never appends) |
| `mailbox-needs-attention` | A mailbox is `paused` (cursor expired — gmail-push.md §5 rebaseline) or `needs_reconnect` (dead OAuth grant) — **inbound mail is not flowing** | `needs_reconnect`: re-run the Part E consent. `paused`: reconnect to rebaseline the cursor, then check for a gap |
| `watch-expiring` | An active mailbox's Gmail `watch` expires in < 72h (or was never armed) — the daily renewal has been failing for days | Function logs for `/internal/cron/watch-maintenance` (`gmail_watch_maintenance` events); a manual `GET` of that endpoint with the cron secret re-arms immediately |
| `webhook-endpoint-auto-disabled` | : a webhook endpoint hit 20 consecutive delivery failures and auto-disabled — a module (or an operator's own integration) has silently stopped receiving events | `SELECT id, url, consecutive_failures FROM webhook_endpoints WHERE status = 'auto_disabled'`; fix the receiving side, then `PATCH /api/v1/webhooks/{id}` with `{"status":"active"}` to re-enable (resets the counter) |
| `webhook-delivery-dead-letter-growth` | : a webhook delivery exhausted its retries in the last 24h (`WEBHOOK_DELIVERY_TOPIC` on `queue_jobs`) | `SELECT payload, last_error FROM queue_jobs WHERE topic = 'webhook.delivery' AND dead_lettered_at IS NOT NULL ORDER BY dead_lettered_at DESC` — `payload.endpointId` names the endpoint; this can precede (or accompany) an eventual auto-disable |
| `watch-expiring` | An active mailbox's Gmail `watch()` expires in < 72h (or was never armed) — the daily renewal has been failing for days. **Only ever raised when push is configured** (HT-94): with no `GMAIL_PUBSUB_*` vars there is no `watch()` to arm, a NULL expiration is the designed steady state, and this alert is suppressed | Function logs for `/internal/cron/watch-maintenance` (`gmail_watch_maintenance` events); a manual `GET` of that endpoint with the cron secret re-arms immediately. On a push-free deployment that GET is a no-op returning `{"skipped":"push-not-configured"}` — if you see this alert there at all, it is a bug, not a mailbox problem |
| `queue-drain-stalled` / `queue-dead-letter-growth` (on a push-free deployment) | The reconcile sweep is the sole inbound transport (HT-94), so sustained queue trouble here means **mail is not arriving at all** | Function logs for `/internal/cron/reconcile-sweep` (`gmail_reconcile_sweep` per-mailbox events, `reconcile_sweep` per-tick summary). Check `SELECT count(*) FROM queue_jobs WHERE topic = 'gmail.reconcile' AND dead_lettered_at IS NULL` — the sweep dedupes on `mailboxId`, so a healthy desk holds at most one live job per mailbox; more than that means the drain is not keeping up |
| `webhook-endpoint-auto-disabled` | HT-69: a webhook endpoint hit 20 consecutive delivery failures and auto-disabled — a module (or an operator's own integration) has silently stopped receiving events | `SELECT id, url, consecutive_failures FROM webhook_endpoints WHERE status = 'auto_disabled'`; fix the receiving side, then `PATCH /api/v1/webhooks/{id}` with `{"status":"active"}` to re-enable (resets the counter) |
| `webhook-delivery-dead-letter-growth` | HT-69: a webhook delivery exhausted its retries in the last 24h (`WEBHOOK_DELIVERY_TOPIC` on `queue_jobs`) | `SELECT payload, last_error FROM queue_jobs WHERE topic = 'webhook.delivery' AND dead_lettered_at IS NOT NULL ORDER BY dead_lettered_at DESC` — `payload.endpointId` names the endpoint; this can precede (or accompany) an eventual auto-disable |

### G3. Structured log events (Vercel log search)

Expand All @@ -320,9 +377,15 @@ handled by the SAME drain), `outbox_drain` (per outbox-drain tick
that claimed at least one `event_outbox` row — claimed/enqueued/dispatched;
quiet ticks don't log, same convention as `queue_drain`), `gmail_reconcile`
(per reconcile job: cursor positions, skip/retry/ack reasons), and
`gmail_watch_maintenance` (the daily renewal + sweep). Correlate transport
events to ingest events on `(mailboxId, providerMessageId)`
(inbound-ingestion.md §6).
`gmail_watch_maintenance` (the daily `watch()` renewal — push deployments
only), `gmail_reconcile_sweep` (per-mailbox sweep decisions: swept, skipped
for no baseline cursor, failed) and `reconcile_sweep` (the per-tick summary
`{total, swept, skipped, failed}`). Correlate transport events to ingest
events on `(mailboxId, providerMessageId)` (inbound-ingestion.md §6).

Unlike the drains, the sweep logs **every** tick including quiet ones: on a
push-free deployment it is the only inbound transport, so its silence is the
sole signal that intake has stopped.

## What this runbook does not cover

Expand Down
79 changes: 79 additions & 0 deletions src/composition/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HEALTH_PATH,
OUTBOX_DRAIN_PATH,
QUEUE_DRAIN_PATH,
RECONCILE_SWEEP_PATH,
SNOOZE_WAKE_PATH,
WATCH_MAINTENANCE_PATH,
} from './app.js'
Expand Down Expand Up @@ -35,6 +36,7 @@ function makeHandler(opts: { cronSecret?: string; uiBaseUrl?: string } = {}) {
const drainOutbox = vi.fn(async () => ({ claimed: 2, enqueued: 2, dispatched: 2 }))
const runSnoozeWake = vi.fn(async () => ({ due: 1, woken: 1 }))
const runWatchMaintenance = vi.fn(async () => ({ total: 1, renewed: 1 }))
const runReconcileSweep = vi.fn(async () => ({ total: 1, swept: 1, skipped: 0, failed: 0 }))
const runHealthCheck = vi.fn(async (): Promise<HealthReport> => HEALTHY_REPORT)
const handler = createAppHandler({
inboxApi,
Expand All @@ -44,6 +46,7 @@ function makeHandler(opts: { cronSecret?: string; uiBaseUrl?: string } = {}) {
drainOutbox,
runSnoozeWake,
runWatchMaintenance,
runReconcileSweep,
runHealthCheck,
})
return {
Expand All @@ -53,6 +56,7 @@ function makeHandler(opts: { cronSecret?: string; uiBaseUrl?: string } = {}) {
drainOutbox,
runSnoozeWake,
runWatchMaintenance,
runReconcileSweep,
runHealthCheck,
}
}
Expand Down Expand Up @@ -146,6 +150,7 @@ describe('createAppHandler — queue drain endpoint', () => {
drainOutbox: vi.fn(async () => ({})),
runSnoozeWake: vi.fn(async () => ({})),
runWatchMaintenance,
runReconcileSweep: vi.fn(async () => ({})),
runHealthCheck: vi.fn(async () => HEALTHY_REPORT),
})

Expand Down Expand Up @@ -202,6 +207,7 @@ describe('createAppHandler — outbox drain endpoint (HT-69)', () => {
}),
runSnoozeWake: vi.fn(async () => ({})),
runWatchMaintenance: vi.fn(async () => ({})),
runReconcileSweep: vi.fn(async () => ({})),
runHealthCheck: vi.fn(async () => HEALTHY_REPORT),
})

Expand Down Expand Up @@ -256,6 +262,7 @@ describe('createAppHandler — snooze wake endpoint (HT-77)', () => {
throw new Error('secret-internal-detail-should-not-leak')
}),
runWatchMaintenance: vi.fn(async () => ({})),
runReconcileSweep: vi.fn(async () => ({})),
runHealthCheck: vi.fn(async () => HEALTHY_REPORT),
})

Expand Down Expand Up @@ -289,6 +296,78 @@ describe('createAppHandler — watch-maintenance endpoint', () => {
})
})

describe('createAppHandler — reconcile-sweep endpoint (HT-94, the primary inbound transport)', () => {
it('runs the sweep and returns its report on a GET with the correct cron secret', async () => {
const { handler, runReconcileSweep, inboxApi } = makeHandler()

const res = await handler(req(RECONCILE_SWEEP_PATH))

expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ok: true,
report: { total: 1, swept: 1, skipped: 0, failed: 0 },
})
expect(res.headers.get('Cache-Control')).toBe('no-store')
expect(runReconcileSweep).toHaveBeenCalledOnce()
expect(inboxApi).not.toHaveBeenCalled()
})

it('rejects a wrong cron secret with 401 and never runs the work', async () => {
const { handler, runReconcileSweep } = makeHandler()
const res = await handler(req(RECONCILE_SWEEP_PATH, { secret: 'wrong-secret-9999999999' }))
expect(res.status).toBe(401)
expect(runReconcileSweep).not.toHaveBeenCalled()
})

it('rejects a missing Authorization header with 401', async () => {
const { handler, runReconcileSweep } = makeHandler()
const res = await handler(req(RECONCILE_SWEEP_PATH, { secret: null }))
expect(res.status).toBe(401)
expect(runReconcileSweep).not.toHaveBeenCalled()
})

it('checks auth BEFORE method — a wrong-secret POST is 401, not 405 (no method oracle for an unauthenticated caller)', async () => {
const { handler, runReconcileSweep } = makeHandler()
const res = await handler(
req(RECONCILE_SWEEP_PATH, { method: 'POST', secret: 'wrong-9999999999' }),
)
expect(res.status).toBe(401)
expect(runReconcileSweep).not.toHaveBeenCalled()
})

it('rejects a non-GET method (authenticated) with 405', async () => {
const { handler, runReconcileSweep } = makeHandler()
const res = await handler(req(RECONCILE_SWEEP_PATH, { method: 'POST' }))
expect(res.status).toBe(405)
expect(runReconcileSweep).not.toHaveBeenCalled()
})

it('answers a generic 500 (never the error text) when the work throws', async () => {
const inboxApi = vi.fn(async () => new Response(null, { status: 299 }))
const handler = createAppHandler({
inboxApi,
cronSecret: CRON_SECRET,
drainQueue: vi.fn(async () => ({})),
drainOutbox: vi.fn(async () => ({})),
runSnoozeWake: vi.fn(async () => ({})),
runWatchMaintenance: vi.fn(async () => ({})),
runReconcileSweep: vi.fn(async () => {
throw new Error('secret-internal-detail-should-not-leak')
}),
runHealthCheck: vi.fn(async () => HEALTHY_REPORT),
})

const res = await handler(req(RECONCILE_SWEEP_PATH))
const bodyText = await res.text()

expect(res.status).toBe(500)
expect(bodyText).not.toContain('secret-internal-detail-should-not-leak')
expect(JSON.parse(bodyText)).toEqual({
error: { code: 'server_error', message: 'Internal server error.' },
})
})
})

describe('createAppHandler — health endpoint (HT-44)', () => {
it('answers 200 with the report VERBATIM (not {ok, report}-wrapped) when healthy', async () => {
const { handler, runHealthCheck, inboxApi } = makeHandler()
Expand Down
Loading