# Push Relay (C127, optional)

Status: design only — not yet built. Relevant when polling latency feels too slow OR when battery / quota matters more than now.

## Problem

mailx polls Google Calendar / Gmail / Outlook for changes. Polling is cheap, simple, and works on every network — the right default. But it has two costs:

1. **Latency floor.** Even at a 5 s syncToken cadence, "the user just rescheduled this on their phone" takes ~5 s to surface in mailx. For most workflows that's invisible; for "alarm fires for an event that was moved an hour ago" it isn't.
2. **Battery / quota on mobile.** The Android shell polls the same way the desktop does; on a phone that's measurable wakeups.

Push notifications (Google's `events.watch`, Microsoft Graph webhook subscriptions, Gmail's `users.watch`) fix both, but only deliver to a public HTTPS URL. The desktop client doesn't have one. So we need a relay.

## Shape

A standalone Node service in `MailApps/relayer/`, deployed wherever the operator runs other small services (alerter-style: tiny Express app, one config file, one long-running process). One relay process serves multiple users; per-user state is keyed by user-issued tokens.

```
┌──────────────┐  watch       ┌─────────┐  webhook   ┌───────────┐  WS/SSE   ┌────────┐
│ Google / MS  │ ──register── │ relayer │ ◀────────  │ relayer   │ ◀────────│ rmfmail│
│ Calendar API │              │ /sub    │            │ /hook/:id │            │ client │
└──────────────┘              └─────────┘            └───────────┘            └────────┘
       ▲                          │                                                ▲
       │  events.watch(           │  per-subscription state:                       │
       │    address: relayer/hook │    upstream channel id, expiry, owner-token,   │
       │    token: per-sub secret │    fan-out endpoints                           │
       │  )                       │                                                │
       └──── done by relayer on   │                                                │
            client request, with  │                                                │
            client-supplied OAuth │                                                │
            access token (kept    │                                                │
            short-lived; client   │                                                │
            re-presents on        │                                                │
            renewal)              │                                                │
                                  ▼
```

Three roles, separable:
- **Subscriber API** — clients POST to register what they want watched.
- **Hook receiver** — public endpoint(s) the upstream services post to.
- **Fan-out** — per-subscription WS or SSE that holds open client connections and pushes payloads.

## API (relayer side)

Generic relay shape — caller specifies upstream service + the params that service needs to register a watch. The relay knows enough about each supported upstream to issue the watch-registration call and decode the inbound webhook envelope; it doesn't introspect payload content.

```
POST /subscribe
  body: {
    upstream:  "google-calendar" | "google-gmail" | "ms-graph-events" | …
    params:    { …upstream-specific watch args, e.g. calendarId, accessToken, expiry preference }
    deliver:   "sse" | "ws"
  }
  → 200 {
    subscriptionId: "<uuid>",
    deliverUrl:     "/sub/<uuid>",      // open SSE / WS here
    authToken:      "<random-secret>",  // present on connect
    expiresAt:      <ms>                // when relay's upstream channel needs renewal
  }

POST /subscribe/<id>/renew
  body: { params: { accessToken: "<fresh OAuth>" } }
  → 200 { expiresAt: <ms> }

DELETE /subscribe/<id>
  → 204 (also calls upstream stop-watch)

GET /subscribe/<id>/status
  → 200 { upstream, expiresAt, lastEventAt, queueDepth }

GET /sub/<id>      (SSE) — emits `data: {payload}\n\n` per upstream event
                   (or WS upgrade) — same payload shape as text frames
```

Auth: `Authorization: Bearer <authToken>` on every call after `/subscribe`. The token issued at subscribe time scopes the subscription; one client manages one or more subscriptions, each with its own token.

## API (client side)

Two settings the user fills in:
- `pushRelayUrl` — e.g. `https://mail1.example.com:NNNN`
- `pushRelayCredentials` — issued by the operator at first contact (one-time, opaque blob; relay verifies)

When `pushRelayUrl` is non-empty, the client at startup:
1. Reads its previous subscription IDs from local config.
2. For each, opens the SSE / WS connection.
3. If the connection refuses with "unknown subscription" (relay restart, expiry), re-subscribes from scratch with the current OAuth token and stores the new id.
4. Hooks the inbound event stream into the same `mailxapi.onEvent` channel the daemon already uses, so the alarm subsystem / message-list / folder-tree all consume push events the same way they consume the daemon's IDLE / sync events.

The client owns OAuth tokens; the relay receives short-lived access tokens at register / renew time, doesn't store refresh tokens.

## Reliability

- **Replay queue per subscription** — last N events (in-memory ring, e.g. N=100) so a brief disconnect doesn't lose events. Client passes `Last-Event-ID` (SSE) or a sequence cursor (WS) on reconnect.
- **Channel renewal** — relay tracks each upstream channel's expiry, schedules renewal at 80% of TTL, calls back to the client (via the WS/SSE itself) for a fresh OAuth token if needed.
- **Fail-open** — if the relay is unreachable, the client's existing syncToken polling continues. Push is "freshness boost," never load-bearing for correctness.

## Generic relay vs per-upstream code

The relay needs per-upstream code for:
- How to register a watch (the API call shape varies).
- How to decode the inbound webhook envelope (Google sends headers in `X-Goog-*`, Graph sends a JSON body, etc.).
- How to renew or stop a watch.

Per-upstream is a small adapter (~50–100 lines each). Adding "Outlook Tasks" or "iCloud Calendar" later is a new file in `relayer/upstreams/`. The fan-out, subscription store, WS/SSE plumbing, replay buffer — all generic, written once.

## Operator footprint

- One Node process, one port, one HTTPS cert (or behind a reverse proxy that handles TLS).
- Storage: SQLite or JSON for the subscription map (small — one row per active subscription).
- Logs to a file the operator already tails.
- No queue infrastructure, no message broker, no DB cluster.

This is the same operational shape as alerter — drop into `MailApps/`, deploy alongside other small services, treat as one more daemon.

## When to build this

Only when polling demonstrably isn't fast enough, OR when mobile battery becomes a real concern (push lets the Android shell wake on event instead of polling on a timer). Polling at 5–10 s syncToken cadence is functionally instant for the "user rescheduled an event" case and quota-cheap. Push is a freshness optimization, not a correctness fix.

## Alternatives considered

- **Cloudflare Workers + Durable Objects** — cleaner per-user-isolation story but requires each user to deploy their own Worker (or one shared operator-run Worker, with a different trust shape). Higher onboarding friction; users without a CF account can't use it. Pro: free tier, edge latency. Con: vendor lock-in, two-click onboarding minimum (account + token).
- **Cloudflare Tunnel / ngrok to the local daemon** — no relay needed, the daemon exposes its own webhook receiver. Catch: only works while the daemon is running; events while offline are lost (recovered by the next syncToken poll, which already exists). Per-instance tunnel.
- **Status quo (poll only)** — what mailx does today. Right default; this doc is for when "default isn't enough."

## Files (when built)

```
MailApps/relayer/
  package.json
  tsconfig.json
  index.ts           — Express server + WS upgrade + SSE
  upstreams/
    google-calendar.ts
    google-gmail.ts
    ms-graph-events.ts
  subs.ts            — subscription store (SQLite)
  fanout.ts          — per-sub connection pool + replay buffer
  readme.md          — operator doc (deploy + config)
```

Client side: extend `mailx-service` with a `pushRelayClient.ts` that opens / re-opens / re-subscribes; surface its status in the existing sync-status banner.
