SAFE_ROUTES has 19 entries (backends.js), not 18. reference.md and reference.zh-CN.md both said '18 entries'; both now say 19. reference §4 said the /api/enabled and /api/admin/session calls are 'admin-session Bearer-auth'd'. The code says otherwise: config_authorized (notify.rs:423) accepts the shared worker token OR a valid admin session, and create_admin_session (notify.rs:1187) is gated by worker_authorized against the admin credential CONFIG_ENCRYPTION_KEY — it is what mints the session, so it cannot be session-auth'd. Both languages now state the two distinct credentials and why adding /api/enabled opens no new credential path. deployment §10.8 said every request-bearing entry point, listing /healthz and /api/*, calls refresh_business_config. The actual 10 call sites cover 8 distinct paths (/ready, /worker, /reconcile, /push/jmap, /webhook/tg, /api/config, /api/business-config, /api/telegram/register-webhook). GET /healthz is a static 200 "ok" and never refreshes; /api/status, /api/enabled, /api/push/register, /api/push/disable and the /api/admin/session pair do not either. Both languages now list the real set and name the exclusions. design.md §5.7 listed the search trigger prefixes as English 'search'/'find'/'retrieval'; worker.rs:689 accepts the Chinese prefixes 搜索/查找/检索 and the help text states English aliases are not supported. The two user-facing strings were also mistranslated: the empty-query guidance prompt is 请提供搜索关键词,例如:/search 发票 (worker.rs:623) and the no-match reply is 没有找到匹配「<query>」的邮件。 (worker.rs:792). The degraded-rendering marker is likewise a Chinese string. SEARCH_SUBJECT_MAX=120 / SEARCH_PREVIEW_MAX=160 / SEARCH_LIMIT=10 verified unchanged. design.md §5.6/§5.7 were machine-translated from Chinese back into English and were broken: 'the first 300 if it fails. characters', a stray closing ** mid-sentence, '(none) deny_unknown_fields)' with a mismatched paren, and 5 zero-width-space artefacts. charter.md:168 had a broken '**Tables, lists, or values may not be copied across documents. **' and opengaps.md had 4. All ZWSPs removed from docs/. deployment.zh-CN.md §10.5/§10.6 were thin mistranslations: 'Worker 域' for 'Worker URL', the debug-token requirement dropped entirely, and a mangled '两者都使用用于验证部署的可用和版本'. Rewritten against the EN. Also replaced a worker.rs:227 line anchor with the MetadataWorker doc comment; that drops the repo's line-refs from 72 to 71, so new text no longer adds to the drift surface. No code changes; GATE-DOCS 6/6 (71 anchors, 455 section refs, 727 path refs). |
||
|---|---|---|
| .github/workflows | ||
| cloudflare-worker | ||
| docs | ||
| scripts | ||
| src | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Dockerfile | ||
| README.md | ||
| README.zh-CN.md | ||
| build.rs | ||
| hoststack.yaml | ||
README.md
MessageWeave
中文文档 / Chinese documentation → README.zh-CN.md
One Telegram bot per mailbox. New mail arrives as metadata; the original is always fetched read-only from your own JMAP server.
1. What it is
MessageWeave sits between a JMAP mail server (for example Stalwart) and a Telegram bot.
- Inbound. A JMAP push subscription tells the service that a message changed. Nothing is polled at rest.
- Outbound. Each event becomes a short, metadata-only Telegram notification: sender, subject, timestamp. Mail bodies are never sent to Telegram.
- On demand. When you ask, the service reads the original through a fresh JMAP read call. Nothing is cached in between.
- Optionally summarised. If you explicitly opt in, an OpenAI-compatible LLM returns a short summary. Requests, responses and attachment bytes are never persisted.
State is an architectural boundary here, not a preference.
- Redis is the only state store. No database, no embedded store, no second store.
- The process writes nothing to disk. No log files, no data files, no local volume.
- No long-lived connections. No SSE, no WebSocket, no long polling.
- Logs go to stdout only. The platform's collector owns log storage and retention.
- What it is not. It is not a mail client, not a proxy, and not a relay. It forwards metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic.
2. Two ways to run it
One image, one Redis, one SPA. The only difference is what sits in front and which URL you hand to Telegram and Stalwart.
The Cloudflare Worker gateway is optional. Nothing in the backend requires it, and the backend runs identically with or without it. It exists for one reason: Cloudflare's Load Balancer product is not available on the free plan, so a Worker is the way a free-plan account puts load sharing and failover in front of several backends.
It is also not a security layer, and it is not meant to be one. There is no intent to hide the backends and no intent to harden them; the origins stay directly reachable whether the gateway is present or not, and no access control is added at the edge. The fixed route safelist is what makes it a load balancer for a bounded set of paths, not a firewall.
| Form A: backend origin only | Form B: behind the Cloudflare Worker gateway | |
|---|---|---|
| What answers requests | one backend host | one Worker URL, N backend origins |
| Failover | none — a down origin stays down | bounded: 2 attempts by default, only timeout and 5xx retry |
| Callback URL you register | https://a.example |
https://lb.example |
| Backend access | direct from the network | equally direct — the gateway adds no access control |
| Gateway route set | n/a — there is no gateway | fixed at 19 paths; unknown paths answer 404 |
| Health to watch | GET /ready on the origin |
GET /ready proxied, plus GET /healthz-worker for the gateway aggregate |
| Extra moving parts | none | one Worker deploy, one origin list |
| Docs | docs/deployment.md §2 |
docs/deployment.md §10, cloudflare-worker/README.md |
Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or whenever you want one stable callback URL while you roll or rebuild an instance.
Switching is a re-registration, not a migration. Registration is idempotent and registering a
new address retires the previous subscription, so moving from Form A to Form B, or back, is one
field in the SPA. Do not register both origins at once: there is a single registration slot, and
the platform entrance must stay internal (C-LB-SINGLE-REG-URL).
No secret moves to the gateway in Form B. It holds no business credential and checks no auth
header. Instead every backend must carry the same SAF-AUTH-* secrets and the same encrypted
business config, because a callback can land on any instance and no instance can prove it was
the one asked for (C-LB-SHARED-SECRETS). Mis-matched secrets show up as a random 401, not as
a routing error.
POST /api/bootstrap and /debug/* are not in the gateway's route set, so requests addressed
to the Worker URL never reach them. That is the safelist working as designed for the paths that
need to be load balanced; it is not hiding anything, because the same origins are reachable at
their own addresses in both forms.
3. 5-minute setup
Requires a reachable Redis instance and a JMAP session plus a Telegram bot token.
This is a local container smoke test. Production deployment can use any platform that
can run the backend container, inject encrypted secrets, and connect to externally managed
Redis. See docs/deployment.md §3 and the platform-specific reference in docs/reference.md.
cp .env.example .env # fill REDIS_URL and CONFIG_ENCRYPTION_KEY
docker build -t messageweave:latest .
docker run --env-file .env -p 8080:8080 messageweave:latest
Then open http://localhost:8080 in a browser and enter your CONFIG_ENCRYPTION_KEY
value. The SPA exchanges it with POST /api/admin/session for a 1,800-second admin session,
then writes the business secrets to Redis with PUT /api/business-config. No environment
variable is read or written again for business configuration.
For local builds without Docker: cargo build --locked produces the message-weave binary.
If the service answers requests but does nothing useful, check the logs for a startup warning: a missing required variable does not crash the process. It serves a read-only setup router over in-memory state instead. See §4.
4. Configuration entry points
Three layers. They are not interchangeable.
Boot-time environment — 2 required, 1 optional.
| Variable | Required | Default |
|---|---|---|
REDIS_URL |
yes | — |
CONFIG_ENCRYPTION_KEY |
yes | — |
PORT |
no | 8080 |
There is no RUN_MODE: the legacy environment parser was removed, and webhook plus
reconcile traffic share one router (POST /reconcile is a standalone endpoint).
.env.example ships only the two required
variables; the rest are documented defaults.
Business configuration — Redis, written by the browser.
POST /api/bootstrap is the one-shot trust bootstrap; PUT /api/business-config hot-
reloads individual settings within about one second. The process never reads business
secrets from the environment in normal operation.
Health.
GET /healthz returns 200 unconditionally — it is liveness, not readiness. GET /ready
returns 503 until configuration, Redis, and both upstreams (a JMAP session GET and
Telegram getMe, 3s each) are reachable. Probe /ready, not /healthz, when deciding
whether to route traffic — but /ready is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the gateway aggregate GET /healthz-worker when the gateway
is enabled.
Remote debug (opt-in, off by default).
/debug/* is an optional remote-debug surface: live JMAP and Telegram probes, the current
business config, and a single Telegram send. It sits behind a two-factor gate — the process
must be started with --debug and DEBUG_TOKEN must be set; miss either and the routes
do not exist at all (requests fall through to a generic 404). Nothing under /debug/* is on
the gateway allowlist, so it is reachable only on the backend origin itself. The only rule for
running it is to keep it off: leave --debug out of the start command and leave DEBUG_TOKEN
unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first — with
an empty allowlist the test send is not restricted to any chat. See
docs/deployment.md §2.1.
5. Security boundary, in one sentence
All state lives in Redis; the process reads only two environment variables and writes nothing to disk.
One corollary matters for setup: the SPA admin credential — and therefore the bootstrap
trust root — is CONFIG_ENCRYPTION_KEY itself, the single 32-byte hex value you supply at
startup. It is only compared in constant time, and is never echoed, logged, or stored. The
Redis ACL password, if you have one, authenticates the Redis connection alone and is not
the credential for any HTTP endpoint.
The optional Cloudflare Worker gateway in front of the backends is not part of this boundary. It holds no credential and checks no auth header, and the backends behind it are directly reachable either way, so nothing about it changes what stands between an attacker and your mail. See §2.
6. Where to read more
| Document | What it answers |
|---|---|
docs/design.md |
Why the system is shaped this way: data flow, JMAP semantics, Redis streams, AI consent rules |
docs/deployment.md |
How to deploy: Dockerfile, secrets, Redis hosting, webhook/push/reconcile routing, multi-instance load balancing |
docs/reference.md |
Single source of truth for verifiable facts — Redis keys and TTLs, error codes, routes, environment layers, budgets |
docs/opengaps.md |
Gaps, blockers and the next phase |
docs/retired.md |
What was tried and dropped — abandoned routes, unreleased designs, and names that never existed |
docs/charter.md |
Project charter: goals, locked technology choices, security invariants, prohibitions, and the stable-ID registry |
cloudflare-worker/README.md |
The Cloudflare Worker gateway: route safelist, timeout and retry budget, bounded failover, health aggregate, deploy |
AGENTS.md |
Language-agnostic engineering norms: code style, config and secrets, build environment, testing gates, document governance |
Facts that matter are traceable. If two documents disagree, docs/reference.md wins.