MessageWeave/docs/reference.md

652 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MessageWeave — Reference
> **This file is the single source of truth for verifiable facts.**
> When any other document disagrees with this one, this one wins.
>
> **This file carries no source line numbers, by design.** They are not part of the
> verification: this file documents *which symbol* does *what*, and line numbers are
> renumbered by every unrelated edit to the same file. `docs/deployment.md` keeps its
> line numbers, because that file is an operator runbook read mid-incident.
>
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
> `src/state.rs`, `src/worker.rs`, or `src/notify.rs`, or to **any Redis key name or TTL**,
> must update this file in the same change. Reviewers: reject a change that touches those
> files without touching this one.
Conventions used throughout:
- **TTL** is in seconds. "No EX" means the key never expires and lives until explicitly deleted.
- **Writer / Reader** name the code symbol that performs the operation. Symbol names are
the identity; this file deliberately records no line numbers (see the note above), so
it stays true across renumbering.
---
---
## 1. Redis keys
### 1.1 Configuration
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `config:business` | No EX | `set_business_config`; first-time write via `SET NX` in `initialize_business_config` | `get_business_config` |
| `config:business:revision` | No EX | atomic `INCR` on every config write; `SET 1 NX` on first-time init | `business_config_revision` |
| `config:outbound` | No EX | `set_outbound_config` | `get_outbound_config` |
| `config:enabled` | No EX | `set_enabled` | `is_enabled` |
| `config:admin_session` | EX 900 | `put_admin_session` | `admin_session_valid`; cleared by `revoke_admin_session` |
Notes:
- `config:business:revision` is an **atomic Redis `INCR`**, not a read-modify-write. Every
request-bearing entry point calls `refresh_business_config`, which only
rebuilds the worker when the remote revision exceeds the cached local value — that
guard is what keeps a stale or malformed snapshot from turning into a rebuild loop.
- `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write
time; the handler reports the same window in its response body as `expires_in: 900`
on the handler side.
- `config:enabled` is consulted as a gate; missing or `false` keeps business processing off.
### 1.2 AI consent
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` | `ai_consent_until`; cleared by `clear_ai_consent` |
The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX`
carries the same duration, so the key removes itself.
Trigger words are exact Chinese literals with **no English aliases** — the in-app help text
enforces this itself (in-app help text in `worker.rs`):
| Input | TTL | Label |
|---|---|---|
| `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | `1小时` |
| `临时`, `一次` (substring) | 3600 | `临时1小时` |
| `今天` | 86_400 | `今天` |
| `7天` | 604_800 | `7天` |
| `直到我撤销`, `长期` (substring) | 31_536_000 (365 d) | `直到撤销(最长365天)` |
| `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke |
The `/ai …` slash forms are exact matches on the whole message; the Chinese phrases are
substring matches anywhere in the message (both inside `parse_intent`, `worker.rs`).
`1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**:
typing a label alone grants nothing. Consent never auto-renews.
The same `parse_intent` (`worker.rs`, variants in `Intent`) routes `/search <关键词>`
and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to
`Intent::Search`. Search grants no consent and writes no Redis key.
### 1.3 Delivery pipeline
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (a `XADD`), called from `telegram_webhook` and `jmap_push`; reconcile path via `claim_dedup_and_enqueue` (`worker.rs`) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` |
| `delivery:inflight:{stream}:{id}` | EX 60 | key built and claimed with `claim_dedup` in the delivery path | `release_dedup` on completion |
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built and claimed with `claim_dedup` in the delivery path | `dedup_exists` before send |
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq`'s Lua script (`INCR` then `EXPIRE` on the same key) | reclaim path |
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | the same Lua script's `XADD`; the name is passed in from `retry_or_dlq`'s call site in the worker (with `max_attempts` 3) | **nothing in code reads it** |
| `state:jmap:since` | none (durable) | `set_reconcile_state` | `get_reconcile_state` before each pass; encoding per §6.5 |
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
pending-entries list (PEL), which Redis maintains internally.
The stream names above are concrete: `stalwart:jmap` and `stalwart:telegram`;
`{stream}` in the remaining keys is one of these two.
The mistake is recorded in `docs/retired.md`.
Delivery is at-most-once per event: the `delivery:inflight` key is claimed with a 60 s window
before the outbound send, and `delivery:committed` extends the guarantee to seven days after
acknowledgement.
The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` /
`stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ
append and source acknowledgement are atomic, all inside that one Lua script. No code path reads the DLQ
back — replay is an operator action, not a service feature.
### 1.4 Idempotency and rate limits
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `dedup:tg:{update_id}` | EX 86_400 | built and claimed with `claim_dedup` in `telegram_webhook`, released with `release_dedup` on a failed enqueue | — |
| `dedup:jmap:{account}:{email}` | EX 86_400 | built and claimed with `claim_dedup` in `jmap_push`; the same key is rebuilt on the reconcile path and claimed with `claim_dedup_and_enqueue` (`worker.rs`) | — |
| `ratelimit:push-verify:{subscription}` | EX 30 | built and claimed with `claim_dedup` in `register_push`; a `false` claim is the `429 push_verify_rate_limited` branch | — |
| `state:jmap:since` | **No EX** | `set_reconcile_state` (a `SET` with no `EX`) | `get_reconcile_state` |
`state:jmap:since` is the only state key that survives indefinitely without an explicit
delete, by design: it is the reconciliation cursor, and expiring it would force a full
re-baseline on the next restart.
### 1.5 Locks
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `lock:reconcile` | 300 | `acquire_lock` in `reconcile` | `renew_lock` from the heartbeat task (90 s) |
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` in `register_push` | `release_lock` on every exit path of `register_push` |
**Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest
configured JMAP request timeout (300 s), otherwise a slow registration could admit a
duplicate. Source comment:
> The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a
> slow create from admitting a duplicate.
### 1.6 Push subscription state
| Key | TTL | Writer | Reader |
|---|---|---|---|
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription`, called from `register_push` | `push_subscription_verified` |
| `push:subscription:id` | No EX | `remember_push_subscription_id` | current-subscription lookup |
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status`; `pending` on verification request, `verified` on success, `disabled` on disable | **none** |
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`, called from `register_push` | `get_push_subscription_for_callback`; removed by `remove_push_subscription_for_callback` |
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` | orphan sweep |
**Two distinct keys that are frequently confused. Read this before editing either.**
- `push:registration:{sha256(callback_url)}` is a **mapping from callback URL to
subscription id**, TTL seven days. It is not a lock.
- `lock:push-register:{sha256(callback_url)}` is the **single-flight registration lock**,
TTL 360 s. It is not a mapping.
They use the same digest but are separate keys with unrelated lifetimes and purposes.
**Annotation: `push:subscription:{id}:status` is write-only by design.**
The source carries an explicit note on `state.rs`:
> `push:subscription:{id}:status` is currently write-only
It is an ops / observability trail intended for `redis-cli` inspection; it is **not** a gate
and nothing reads it for authorization. The three values ever written are `pending` (900 s),
`verified` (300 s) and `disabled` (86400 s) — see the table above for their call sites.
Do not infer correctness from its presence.
---
## 2. Error envelope
Every error response uses one envelope shape:
```json
{ "error": "<machine_code>", "request_id": "<id>" }
```
`error` is a **plain machine-readable string**, not a nested object — there is no `message`
field. `request_id` is always present.
| Status | Code | Retry-After |
|---|---|---|
| 400 | `invalid_request`, `missing` | — |
| 401 | `unauthorized` | — |
| 403 | `forbidden`, `chat_not_allowed` (`/debug/*` only) | — |
| 404 | `push_subscription_not_found` | — |
| 409 | `conflict` | — |
| 422 | `invalid_configuration` | — |
| 429 | `push_verify_rate_limited` | `30` |
| 500 | `internal_error` | — |
| 502 | `telegram_send_failed` (`/debug/*` only) | — |
| 503 | `service_unavailable`, `disabled`, `reconcile_retry`, `push_verify_failed`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures |
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
string `"30"`:
- `error_response` sets it **only when its `retry` flag is true**. The readiness failure
path passes `retry = true`, so `/ready` is covered by that rule rather than by a
special case.
- `error_response_with_id` sets it **unconditionally**. It is used only for the push
register/disable failures `push_state_unavailable` and `push_destroy_failed`, which are
always `503`.
`GET /api/status` is the exception that reports setup state with a non-envelope body.
---
## 3. Backend routes
All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap`.
The three static SPA routes in the last table row are the exception: they live in
`web::router()` (`src/web.rs`) and are merged into the router via `.merge(web::router())` —
in the setup-mode router and again in the production router. The setup-mode router mounts
only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or
admin route; §5.1 explains why that is deliberate.
| Method | Path | Handler |
|---|---|---|
| POST | `/webhook/tg` | `telegram_webhook` |
| POST | `/push/jmap` | `jmap_push` |
| POST | `/api/push/register` | `register_push` |
| POST | `/api/push/disable` | `disable_push` |
| POST | `/reconcile` | `reconcile` |
| POST | `/worker` | `worker` |
| GET, PUT | `/api/config` | `get_config` / `put_config` |
| GET, PUT | `/api/enabled` | `get_enabled` / `put_enabled` |
| GET, PUT | `/api/business-config` | `get_business_config` / `put_business_config` |
| POST | `/api/business-config/preflight` | `preflight_business_config` |
| POST | `/api/bootstrap` | `bootstrap` |
| POST | `/api/admin/session/revoke` | `revoke_admin_session` |
| POST | `/api/admin/session` | `create_admin_session` |
| GET | `/healthz` | `healthz` |
| GET | `/ready` | `ready` |
| GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` |
The remote-debug surface (`debug_router`, `src/debug.rs`) is merged **only when the
debug surface is requested — `--debug` on the command line or a truthy `DEBUG_ENABLED`
env var — and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`; the gate is read in
`src/main.rs` before either router is built). Otherwise none of these routes exist and
requests fall through to axum's generic `404`, not a 401. All seven require
`Authorization: Bearer <DEBUG_TOKEN>`, checked by `debug_authorized`, which delegates to
the production `worker_authorized` so the comparison is constant time.
| Method | Path | Handler |
|---|---|---|
| GET | `/debug/ping` | `debug_ping` |
| GET | `/debug/config` | `debug_config` |
| GET | `/debug/redis` | `debug_redis` |
| GET | `/debug/jmap` | `debug_jmap` |
| GET | `/debug/telegram` | `debug_telegram` |
| GET | `/debug/worker` | `debug_worker` |
| POST | `/debug/notify` | `debug_notify` |
Response conventions:
- `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be
used to decide whether to route traffic.
- `GET /ready` returns **503** when configuration, Redis, or an upstream probe is not ready,
and carries `Retry-After: 30` for not-ready responses. It checks four things: config
completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
`GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and
`GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms
and run **in parallel** (`tokio::join!`), so the worst case
is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields
are real probe results. The probes are plain reusable functions (`probe_jmap_session`,
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw
session URL unauthenticated would report not-ready forever and make ingress stop routing.
- `GET /api/status` (**always 200**) returns
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`.
`ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`,
`CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is
safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs`
(`<git-sha-or-nogit>+<UTC build time>`) and is how the SPA footer proves a deploy landed.
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
config is not loaded or there is no outbound client — `service_unavailable` with
`Retry-After: 30`. The three probe endpoints instead report failure
**inside a 200 body** (`{"ok":false,"detail":...}`), so an unreachable upstream never looks
like an outage. `POST /debug/notify` also returns `403 chat_not_allowed` when the chat is
outside a *non-empty* allowlist, and `502 telegram_send_failed` on
a send failure.
- Static assets are served with a strict CSP; see §7.
---
## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 16 entries, alongside `ROUTE_METHODS` (`index.js`)
> which fixes one method set per path. The worker entry point is `src/index.js`
> (`wrangler.toml`); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`).
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
all: a path missing from `SAFE_ROUTES` returns **404**, a registered
path with the wrong method returns **405**, and a missing or unparseable
backend pool returns **503** rather than passing the request through.
**Forwarded by the worker (16):**
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
`/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` ·
`/api/admin/session/revoke` ·
`/api/enabled` ·
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
`/ready`
**Registered on the backend but NOT forwarded (4):**
| Path | Why it is absent from the gateway |
|---|---|
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path |
| `GET /healthz` | Liveness is aggregated by the gateway itself |
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly |
`GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the
admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled`
reads it, the toggle writes it); both calls are
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded
`/api/admin/session` pair.
Consequence: none of the four carries external business traffic, so no second ingress is
needed in front of the backend instances. The SPA's first-boot flow still cannot drive
`/api/bootstrap` through the worker — bootstrap must be performed against the backend origin
directly, or the bootstrap path must be added to the gateway allowlist.
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
proxy: the callback URL is supplied by the client in the request body and
validated as a URL before anything is written, and every push subscription record is
written to and read back from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`),
so it does not matter which backend instance the worker picks. Push
registration therefore no longer requires hitting a specific backend address — see
deployment.md §10.5.
---
## 5. Environment variables
Three distinct layers. They are not interchangeable.
### 5.1 Boot-time (2 required, 2 optional with defaults)
| Variable | Required | Default | Source |
|---|---|---|---|
| `REDIS_URL` | yes | — | read at startup in `src/main.rs` |
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` |
| `PORT` | no | `8080` | `unwrap_or(8080_u16)`. The listener binds it directly; `Config` carries no port field |
| `DEBUG_ENABLED` | no | — | truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) |
| `DEBUG_TOKEN` | no | — | effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
`RUN_MODE` **no longer exists**: the identifier was removed together with the legacy
`Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook
and reconcile traffic share one router and reconcile is a standalone `POST /reconcile`
endpoint, so the variable never changed any runtime behaviour — there is now nothing to set.
**Missing a required variable does not crash the process.** It logs a warning and serves
`router_configuration_setup`, which mounts only `/api/status`,
`/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not
registered at all: `admin_token` is empty, so `constant_time_eq` would reject
every candidate forever, and `MemoryState` could not persist a bootstrap
write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401`
that reads as "retry with a better credential". The container stays up and answers the status
surface while doing no business work. This is a deliberate fail-closed-to-setup posture, and it
is the single most likely cause of "the container is healthy but nothing happens".
### 5.2 Redis-resident business configuration
Read by the SPA through `GET /api/business-config` and written through
`PUT /api/business-config` (effective within 1 s); the first successful write creates the
configuration, later writes hot-reload it. `GET` returns four keys — `configured`, `revision`,
`values`, `secrets_present` — holding the 10 non-secret fields plus one presence boolean per
secret (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`,
`worker_token`, `llm_api_key`), never the secret values themselves (`SAF-NO-SECRET-ECHO`). With
nothing saved yet it returns `200`, `configured: false`, `revision: 0`, an empty `values` and
every flag false, so the SPA needs no special-case code path. The PUT body is a **partial
patch** that replaces only the fields it names and keeps the stored value for the rest (`apply`
in `config.rs`); a secret omitted from the patch keeps the stored secret, so prefilled form
values are safe to resubmit. An explicitly submitted empty string is stored as-is and really
clears the secret — "blank means unchanged" is a client-side contract, enforced by the SPA
dropping blank secret fields before it sends, not by the server.
The increment semantics only hold once a configuration exists. With nothing stored there is no
value to fall back to, so an incomplete patch is rejected with **422 `invalid_configuration`**
— the first save has to be complete. `POST /api/bootstrap` against the backend origin is the
one-shot write path that creates a configuration from scratch, for automation. The process never
reads these from the environment in normal operation.
**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write,
and on a `PUT` it runs against the **merged** configuration, not the submitted body: the patch
is never validated on its own, and the full stored wire is never discarded (`apply` in
`config.rs`). `validate_business_wire` runs first and a rejection is a genuine `422`. Once
the merged wire is valid it is *always* persisted — the merged wire, not the request body — and
only then are the clients built and the running
worker swapped. A failed build therefore returns **200** with `persisted: true`,
`runtime_applied: false` and a `warnings` array of `{component, step, detail}` objects, where
`component` is `jmap` or `llm` and `step` is `connect`, `account`, `config` or `build`, rather
than a 503. The configuration is saved and the outage is reported instead of hidden.
`runtime_applied` is
`true` only when the reload was committed. This is deliberate: an unreachable JMAP must not
turn a valid configuration into a silent data loss. `PUT /api/business-config` and
`POST /api/bootstrap` both follow this contract; the revision is returned in both cases as the
`x-business-config-revision` header, and only `PUT /api/business-config` also returns it in the
response body, as `"revision"` (a `u64`). The SPA surfaces it in its status line and sends it
back in the request body as the `revision` control field, which the backend compares against the
stored revision and answers `409 conflict` on a mismatch. Bootstrap's
body carries no `revision` field, and a `PUT` body that omits it keeps last-write-wins.
`POST /api/business-config/preflight` (`preflight_business_config`) runs the identical
validation and client build against the submitted wire and returns the per-component verdict
without writing anything and without touching the running worker:
```json
{
"persisted": false,
"validation": { "ok": true, "errors": [] },
"components": {
"jmap": { "ok": false, "errors": [{ "component": "jmap", "step": "connect", "detail": "..." }] },
"llm": null
}
}
```
`components` is `null` when validation already failed (there is no point probing clients from a
wire that was rejected), and `llm` is `null` when `llm_enabled`/`llm_allow_net` are false
rather than vacuously `ok`. A preflight whose answer is "the configuration is bad" is a
**200**, because that is the answer the caller asked for. Authentication is the same
`config_authorized` gate as the write path; it is deliberately **not** behind `DEBUG_TOKEN`.
### 5.3 No legacy environment path
`Config::from_env()` and its helpers (`required_secret`, `required_nonblank`, `env_bool`) are
**deleted** — `config.rs` performs no `std::env` read at all. The names below were the legacy
env surface and are listed only so a stale deployment script can be recognised as stale
(registration in `docs/retired.md`):
`RUN_MODE` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · `LLM_ENABLED` · `LLM_ALLOW_NET` ·
`LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_SUMMARY_TARGET_CHARS` · `BOT_TOKEN` ·
`JMAP_SESSION_URL` · `JMAP_USERNAME` · `JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` ·
`TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
The `JMAP_*` family is exactly three names. In production the account id arrives as the
`accountId` request-body field on `jmap_push` and is held on the client as
`account_id`; the only remaining environment reads of these
four names are inside the `#[ignore]` real-server smoke test in
`src/domain/jmap/client.rs`, which is not part of the production configuration surface.
The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
`TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable.
**Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup
and held as `admin_token`, it is checked in constant time by
`worker_authorized` at the top of both
`POST /api/bootstrap` and `POST /api/admin/session`. It is
only compared against the request bearer — never echoed, logged, or stored. The session
issued by `/api/admin/session` is a freshly generated random 32-byte hex token
(generated per call), never the credential; only its digest is retained in Redis under
`config:admin_session`. `REDIS_URL` is the Redis connection string alone: an ACL password in
it, if any, authenticates the Redis connection and is not the credential for any HTTP
endpoint.
### 5.4 Notification rendering
`send_notification` (`src/channel.rs`) is the only function that renders a notification into
Telegram text, and it renders exactly three metadata lines — never the body, and never an
LLM summary:
```text
From: Zhang San <zhang@example.com>
Subject: Q4 budget
Received: 2026-09-28 09:15
```
`From:` is the JMAP `EmailAddress` in standard display form: `Name <address>` when the
sender carries a display name, the bare address otherwise. Both `From:` and `Subject:`
fall back to a literal (`unknown` / `(no subject)`) only when JMAP did not return the
property.
`Received:` is rendered at notification time, not stored as text. The server keeps the
JMAP `receivedAt` as a Unix second and converts it to wall-clock time in the configured
timezone using `%Y-%m-%d %H:%M` — 24-hour clock, minute precision, no AM/PM, no seconds.
When `receivedAt` is absent, or falls outside the representable instant range for the zone,
the literal `unknown` is shown rather than a clipped timestamp.
The timezone is a business-configuration field, `timezone` (`REQ-TIMEZONE-DISPLAY`): an
IANA identifier, default `Asia/Shanghai`. Only 16 zones are accepted, every one of them
without daylight saving transitions — `Etc/UTC`, `Africa/Cairo`, `Europe/Istanbul`,
`Africa/Nairobi`, `Asia/Dubai`, `Asia/Karachi`, `Asia/Kolkata`, `Asia/Bangkok`,
`Asia/Ho_Chi_Minh`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Singapore`,
`Asia/Manila`, `Asia/Tokyo`, `Asia/Seoul`. Any other identifier is rejected with **422**
rather than silently falling back to a guess. This is a deliberate limitation: the offline
build has no IANA tz database (`chrono-tz` is unavailable), so each zone resolves to a
fixed offset and never tracks a transition. None of the sixteen needs one, which is why
`Asia/Shanghai` can be the default. Like the rest of business configuration it is edited
in the SPA and applied hot, with no restart.
---
## 6. Outbound and runtime parameters
### 6.1 Outbound
| Parameter | Default | Range / cap | Source |
|---|---|---|---|
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
| `max_retries` | 3 | hard cap 5 | default in `state.rs`; rejected in the write path; re-clamped in `channel.rs` |
There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
`config:outbound` and is bounded at 5 regardless of what is written.
### 6.2 Reconcile budgets
| Constant | Value | Source |
|---|---|---|
| `BASELINE_PAGE_SIZE` | 100 | `worker.rs` |
| `BASELINE_MAX_PAGES` | 100 | `worker.rs` |
| `BASELINE_MAX_EMAILS` | 10_000 | `worker.rs` |
| `RECONCILE_MAX_PAGES` | 100 | `worker.rs` |
| `RECONCILE_BUDGET` | 20 s | `worker.rs` |
| `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` |
| `RECONCILE_INITIAL_CHANGES` | 100 | `notify.rs` |
| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task |
`initial_changes` is a function parameter, not a constant: it sizes only the
first `/changes` call. The replay phase may widen the window afterwards, up to
`CHANGE_WINDOW_CAP`, before it advances the cursor.
### 6.3 Idle threshold
XAUTOCLAIM's idle threshold is derived from the live outbound config:
```
count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2
```
Floored at the legacy 300 s single-event ceiling and capped at 6 h. The floor is also the
fallback when the config cannot be read, so a
Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at
stake, never loss.
### 6.4 Search limits
| Constant | Value | Source |
|---|---|---|
| `SEARCH_LIMIT` | 10 | `worker.rs` |
| `SEARCH_SUBJECT_MAX` | 120 chars | `worker.rs` |
| `SEARCH_PREVIEW_MAX` | 160 chars | `worker.rs` |
### 6.5 Reconcile cursor
`/reconcile` keeps one cursor, `state:jmap:since`, and it is bimodal:
- `baseline:{hex-encoded-state}:{position}` — a position walk is in flight. The
walk enumerates the collection by position (`list_emails_page`) and `/changes`
has not been replayed from it yet.
- A bare state string — the walk is done; this is a token to resume
`Email/changes` from.
The two modes are an explicit `ReconcileCursor` variant in `worker.rs`, not a
reserved position value. The page cap (`BASELINE_MAX_PAGES`) is a hard stop on
the walk: if the walk is cut there, the cursor is persisted **as a walk**, never
as a replay, because replay mode asserts the listing is exhausted and replaying
`/changes` from a half-listed state would skip whatever still remains.
Two token classes are in play and they are not interchangeable:
- `Session.state` (RFC 8620 §2.1) is minted per session and is **not** an
`Email`-collection token. A fresh or re-baselined cursor carries it, because
the jmap-client `Session` type exposes no per-collection state.
- `newState` out of a `Changes` response **is** a collection token.
A strictly conforming server may therefore reject the very first `/changes` call
after a re-baseline with a `sinceState` error. `replay_changes` absorbs it by
re-baselining to a fresh token and returning, so the mismatch can only ever bite
one call and the next pass restarts the walk; the 24h dedup key
(`ttl::DEDUP_JMAP_SECONDS`) bounds the resulting replay to at most one duplicate.
`Email/changes` also returns `destroyed` and `oldState`. Both are dropped by
`EmailChanges`: a deleted email has nothing left to look up, so it drives no
notification and does not move the cursor, and `oldState` echoes the
`sinceState` the server accepted, which the caller already holds. Keeping either
would have meant storing a value nothing reads.
---
## 7. Static assets and response headers
Served from `src/web.rs`.
- Three routes only: `GET /`, `GET /assets/config.js`, `GET /assets/styles.css`.
Everything else returns 404.
- Assets are embedded at compile time with `include_str!`. There is no
filesystem access at runtime.
- Every response carries `Content-Type`, `Cache-Control: no-store`,
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
- CSP is applied **only to the HTML document**: inserted inside
an `if html` block:
```
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self';
form-action 'self'; base-uri 'none'; frame-ancestors 'none'
```
- `X-Frame-Options: DENY` is set alongside the CSP, on the HTML response only.
`default-src 'none'` is deliberately stricter than `'self'`: the page is closed by default
and only `script-src`, `style-src` and `connect-src` re-open a same-origin channel.
---
## 8. Not implemented
Recorded here so that references elsewhere cannot be mistaken for shipped features.
- `/search` **is** implemented (`bfe0fd8`). Adapter: `Intent::Search` in `worker.rs` and
in `src/domain/jmap.rs`, backed by `search_emails` in `src/domain/jmap/client.rs`.
What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes
`emailId`/`subject`/`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so
per-part body highlight cannot be modelled through the locked crate. Search degrades to
`subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design
note in `docs/design.md` §5.7.
- The Cloudflare Worker contains no Rust: `cloudflare-worker/src/` holds only `index.js`,
`lb.js`, `backends.js` and `health.js`. Any `*.rs` path written under `cloudflare-worker/`
is a documentation error, not a source file.
---
## References
External dependencies, all reachable and returning HTTP 200 at the time of verification:
- axum — https://docs.rs/axum
- redis (Rust crate, 0.27) — https://docs.rs/redis/0.27/redis/
- reqwest (Rust crate, 0.13) — https://docs.rs/reqwest/0.13/reqwest/
- jmap-client (0.4.2) — https://docs.rs/jmap-client/0.4.2
- JMAP specification — https://jmap.io/spec/
- JMAP RFC 8620 — https://datatracker.ietf.org/doc/rfc8620/
- Redis Streams — https://redis.io/docs/latest/develop/data-types/streams/
- Redis persistence — https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
- Cloudflare Workers — https://developers.cloudflare.com/workers/
- Cloudflare Workers runtime APIs — https://developers.cloudflare.com/workers/runtime-apis/
- RFC 8030 (Web Push) — https://www.rfc-editor.org/rfc/rfc8030
- RFC 7231 §7.2.3 — https://www.rfc-editor.org/rfc/rfc7231#section-7.2.3
- Dockerfile reference — https://docs.docker.com/reference/dockerfile/
- rust Docker image — https://hub.docker.com/_/rust
- Uptime Kuma — https://uptime.kuma.pet/
- OWASP Logging Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
Not linked because they do not resolve to documentation: `crates.io/crates/jmap-client`
(404) and `platform.openai.com` documentation paths (403). The project describes the LLM
dependency as OpenAI-compatible and documents it through `reqwest` instead.