From 7a27f7ff00aa2d16e7146eb0d537cd2a1c7dce2d Mon Sep 17 00:00:00 2001 From: anonsaber Date: Thu, 1 Oct 2026 02:36:03 +0800 Subject: [PATCH] docs: add bilingual guides and callback setup in SPA --- README.md | 8 +- README.zh-CN.md | 12 +- cloudflare-worker/README.md | 2 +- cloudflare-worker/src/backends.js | 1 + cloudflare-worker/src/index.js | 3 +- cloudflare-worker/test/backends.test.js | 2 +- cloudflare-worker/test/index.test.js | 6 +- docs/charter.md | 446 +++---- docs/charter.zh-CN.md | 291 +++++ docs/deployment.md | 1095 +++--------------- docs/deployment.zh-CN.md | 210 ++++ docs/design.md | 994 ++++++++-------- docs/design.zh-CN.md | 802 +++++++++++++ docs/opengaps.md | 130 ++- docs/opengaps.zh-CN.md | 117 ++ docs/reference.md | 134 ++- docs/reference.zh-CN.md | 765 ++++++++++++ docs/retired.md | 224 ++-- docs/retired.zh-CN.md | 161 +++ scripts/docs_check/audit_anchor_semantics.py | 6 +- scripts/docs_check/audit_anchors.py | 6 +- scripts/docs_check/check_sec_refs.py | 10 +- scripts/docs_check/check_tables.py | 6 +- scripts/docs_check/validate_docs.py | 6 +- src/notify.rs | 96 ++ web/config.js | 90 ++ web/index.html | 9 + 27 files changed, 3787 insertions(+), 1845 deletions(-) create mode 100644 docs/charter.zh-CN.md create mode 100644 docs/deployment.zh-CN.md create mode 100644 docs/design.zh-CN.md create mode 100644 docs/opengaps.zh-CN.md create mode 100644 docs/reference.zh-CN.md create mode 100644 docs/retired.zh-CN.md diff --git a/README.md b/README.md index 70e4ba2..4b85e4e 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,9 @@ State is an architectural boundary here, not a preference. Requires a reachable Redis 7 instance and a JMAP session plus a Telegram bot token. -**This is the local/dev path.** The repo's `Dockerfile` is for local `docker run` only -- -production does not execute it. Live deploys run from `hoststack.yaml` with -`runtime: rust`, which HostStack builds in its own `rust:slim-trixie` image and runs in its -own `debian:trixie-slim` runner, so the Dockerfile's diagnostic tools, `ENTRYPOINT` and -`EXPOSE` do not apply there. See `docs/deployment.md` §3 and §5.1 for the boundary. +**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`. ```sh cp .env.example .env # fill REDIS_URL and CONFIG_ENCRYPTION_KEY diff --git a/README.zh-CN.md b/README.zh-CN.md index 0806bc9..c3319f5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,12 +87,12 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` ** | 文档 | 回答什么 | |---|---| -| [`docs/design.md`](docs/design.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 | -| [`docs/deployment.md`](docs/deployment.md) | 怎么部署:Dockerfile、secrets、Redis 托管、webhook/push/对账路由、多实例负载均衡 | -| [`docs/reference.md`](docs/reference.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 | -| [`docs/opengaps.md`](docs/opengaps.md) | 缺口、阻塞项与下一阶段目标 | -| [`docs/retired.md`](docs/retired.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 | -| [`docs/charter.md`](docs/charter.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 | +| [`docs/design.zh-CN.md`](docs/design.zh-CN.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 | +| [`docs/deployment.zh-CN.md`](docs/deployment.zh-CN.md) | 怎么部署:Dockerfile、secrets、Redis 托管、webhook/push/对账路由、多实例负载均衡 | +| [`docs/reference.zh-CN.md`](docs/reference.zh-CN.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 | +| [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 | +| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 | +| [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 | | [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 | 重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。 diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 4fa0ff9..a45038f 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -36,7 +36,7 @@ ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) - 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`GET|PUT /api/business-config`(读回 + 增量保存)、`POST /api/business-config/preflight`(保存前逐组件连通性预检)。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 -- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`POST /worker`、`GET /ready`。 +- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/telegram/register-webhook`、`POST /api/push/disable`、`POST /reconcile`、`POST /worker`、`GET /ready`。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - method 不符 → **405**。 diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index 1b5883b..6782c50 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -22,6 +22,7 @@ export const SAFE_ROUTES = Object.freeze([ "/webhook/tg", "/push/jmap", "/api/push/register", + "/api/telegram/register-webhook", "/api/push/disable", "/reconcile", "/worker", diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index c93bed4..ff55c4f 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -18,7 +18,7 @@ * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session * - GET /healthz → 透传到源站(源站自己的健康检查) * - GET /healthz-worker → LB 聚合健康(MOD-HEALTH-AGG),响应体带 LB_VERSION - * - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable、GET /ready → 透传 + 有界故障转移 + * - POST /webhook/tg|/push/jmap|/api/push/register|/api/telegram/register-webhook|/api/push/disable、GET /ready → 透传 + 有界故障转移 * - POST /reconcile|/worker → 外部 cron 触发的同步长任务,长超时 + 单发不故障转移 * - 其它 → 404 / 405 * @@ -65,6 +65,7 @@ const ROUTE_METHODS = Object.freeze({ "/webhook/tg": ["POST"], "/push/jmap": ["POST"], "/api/push/register": ["POST"], + "/api/telegram/register-webhook": ["POST"], "/api/push/disable": ["POST"], "/reconcile": ["POST"], "/worker": ["POST"], diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js index 68750e9..bbaefa7 100644 --- a/cloudflare-worker/test/backends.test.js +++ b/cloudflare-worker/test/backends.test.js @@ -46,7 +46,7 @@ test("route safelist forwards /healthz, /reconcile and /worker, blocks unknown p for (const r of [ "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/worker", - "/api/push/register", "/api/push/disable", + "/api/push/register", "/api/telegram/register-webhook", "/api/push/disable", "/api/config", "/api/business-config", "/api/business-config/preflight", "/api/admin/session", "/api/admin/session/revoke", "/api/enabled", "/healthz", diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index 0ed4c9f..c47cbda 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -228,7 +228,7 @@ test("Worker: valid env, GET /ready forwarded to backend with auth header intact } }); -test("Worker: POST /api/push/register and /api/push/disable are forwarded", async () => { +test("Worker: callback registration and disable endpoints are forwarded", async () => { const seen = []; const restore = stubFetch(async (url, init) => { seen.push([url, init.method]); @@ -238,7 +238,7 @@ test("Worker: POST /api/push/register and /api/push/disable are forwarded", asyn }); }); try { - for (const path of ["/api/push/register", "/api/push/disable"]) { + for (const path of ["/api/push/register", "/api/telegram/register-webhook", "/api/push/disable"]) { const res = await handleFetch( new Request(`https://lb.example${path}`, { method: "POST", body: "{}" }), makeEnv(), @@ -246,7 +246,7 @@ test("Worker: POST /api/push/register and /api/push/disable are forwarded", asyn assert.equal(res.status, 400); assert.deepEqual(await res.json(), { error: "invalid_request", request_id: "x" }); } - assert.equal(seen.length, 2); + assert.equal(seen.length, 3); assert.ok(seen.every(([url, method]) => method === "POST")); } finally { restore(); diff --git a/docs/charter.md b/docs/charter.md index 0b4c20c..fec430c 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -1,113 +1,115 @@ -# MessageWeave — 项目章程(Project Charter) +# MessageWeave — Project Charter -> 本文件是**项目专属**规范:项目目标、技术选型、安全不变量、实现阶段、禁止事项、 -> 测试验收、文档边界与**稳定 ID 注册表**。 -> -> 通用、语言无关的代码编写与环境构建规范见 [`../AGENTS.md`](../AGENTS.md); -> 可核对事实(路由 / Redis 键与 TTL / 配置项 / 错误码 / 出站常量)的唯一权威来源见 -> [`reference.md`](reference.md);缺口与阻塞见 [`opengaps.md`](opengaps.md)。 -> -> 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** +> [中文版本 / Chinese version → charter.zh-CN.md](charter.zh-CN.md) -> **代码基线** `fc686ab`(src/ 行号锚点)|文档以当前 `main` 为准,锚点须随代码基线复核。 +> This document is an exclusive specification for the **project: project goals, technology selection, safety invariants, implementation stages, prohibited matters, +> Test acceptance, document boundaries and **stable ID registry**. +> +> For general, language-independent code writing and environment construction specifications, see [`../AGENTS.md`](../AGENTS.md); +> The single authoritative source of verifiable facts (routing / Redis keys and TTL / configuration items / error codes / outbound constants) see +> [`reference.md`](reference.md); for gaps and blocking, see [`opengaps.md`](opengaps.md). +> +> When there is a conflict between the three: **The verifiable facts shall prevail in `reference.md`, the project constraints shall prevail in this document, and the quality rules shall prevail in `../AGENTS.md`. ** + +> **Code Baseline** `fc686ab` (src/ line number anchor)|The document is based on the current `main`, and the anchor point must be reviewed along with the code baseline. --- -## 1. 项目目标 +## 1. Project goals -一个**部署在 Docker + 外部托管 Redis** 的 JMAP 邮件通知服务: +A JMAP email notification service deployed on Docker + externally hosted Redis: -- 通过 JMAP 只读拉取邮件变更,判断是否需要通知。 -- 可选的 LLM 分析(AI 开关 + 用户级外部授权),分析结果**不存储、不进入消息**。 -- 通过 Telegram 向**白名单 chat** 推送**仅含元数据**的通知。 -- 用户通过 Telegram 命令(`/summary`、`/search`)与 `/help` 交互。 -- 通过浏览器 SPA 配置业务参数;通过受保护接口开关业务处理。 +- Read-only pull email changes through JMAP to determine whether notification is needed. +- Optional LLM analysis (AI switch + user-level external authorization), the analysis results are **not stored or entered into the message**. +- Push **metadata only** notifications to **whitelisted chat** via Telegram. +- Users interact with `/help` through Telegram commands (`/summary`, `/search`). +- Configure business parameters through browser SPA; switch business processing on and off through protected interfaces. -**不做**:多租户、账号体系、Web UI 之外的客户端、消息持久化、邮件双向同步。 +**Not done**: Multi-tenancy, account system, clients other than Web UI, message persistence, and two-way email synchronization. -## 2. 技术选型(锁定) +## 2. Technology selection (locked) -| 层 | 选型 | 版本 | +| Layer | Selection | Version | |---|---|---| -| 语言 | Rust | edition 2021 | -| Web 框架 | axum | 0.8 | -| 异步 | tokio | 1 | -| 状态存储 | Redis | redis 0.27 | -| JMAP 客户端 | jmap-client | 0.4.2(`default-features = false` + features `["async","rustls"]`) | -| Telegram | 自建轻量客户端(`src/channel.rs`),**不引入** bot 框架 | — | -| HTTPS 客户端 | reqwest | 0.13 | -| 密钥类型 | secrecy | 0.10 | -| 加密 | ring | 0.17 | -| 日志 | tracing + tracing-subscriber | 0.1 / 0.3 | -| 调度 | 外部 cron → `POST /reconcile` | — | -| 前端 | 原生 HTML + 单文件 JS + 单文件 CSS,**无构建工具、无框架** | — | -| 网关 | Cloudflare Worker(纯 JS,零依赖) | — | +| Language | Rust | edition 2021 | +| Web framework | axum | 0.8 | +| asynchronous | tokio | 1 | +| State Storage | Redis | redis 0.27 | +| JMAP Client | jmap-client | 0.4.2 (`default-features = false` + features `["async","rustls"]`) | +| Telegram | Self-built lightweight client (`src/channel.rs`), **not introducing** bot framework | — | +| HTTPS client | reqwest | 0.13 | +| key type | secrecy | 0.10 | +| encryption | ring | 0.17 | +| Log | tracing + tracing-subscriber | 0.1 / 0.3 | +| Scheduling | External cron → `POST /reconcile` | — | +| Front-end | Native HTML + single-file JS + single-file CSS, **No build tools, no frameworks** | — | +| Gateway | Cloudflare Worker (Pure JS, zero dependencies) | — | -## 3. 安全边界(不可违反) +## 3. Security boundary (cannot be violated) -23 条,编号 `SAF-*` / `REQ-*` / `C-*`,全部登记在 §8 注册表。 +23, numbered `SAF-*` / `REQ-*` / `C-*`, all registered in the §8 register. -1. **JMAP 只读**:不得使用 Send 类接口,禁止修改邮箱任何内容。 -2. **不出站发信**:`src/channel.rs` 是唯一 Telegram 出站出口;领域层禁止直接发信。 -3. **禁止长连接**:不得引入 WebSocket / SSE / 长轮询;所有通信为短请求-响应。 -4. **无状态恢复**:不得用本地文件或内存状态做恢复;崩溃后仅依赖 Redis 中的可重放记录。 -5. **无数据库**:不得引入关系型或文档型数据库。 -6. **无本地写**:除 stdout 日志外,进程不写任何文件。 -7. **日志只走 stdout**:不写日志文件、不接外部日志 agent。 -8. **状态只在 Redis**:一切可变状态只存在于外部托管 Redis。 -9. **密钥只来自环境变量**:进程配置不从 Redis 读取。 -10. **密钥不回显**:任何日志、错误响应、Debug 输出都不得包含密钥本体。 -11. **密钥常量时间比较**:管理凭据与 admin session 摘要必须常数时间比对。 -12. **通知只含元数据**:Telegram 通知只含发件人 / 主题 / 时间(+ 附件数),正文绝不进入通知。 -13. **AI 结果不进入消息**:LLM 输出只用于决策,不落 Redis、不进入 Telegram 消息。 -14. **AI 需用户级外部授权**:只有显式授权过且未过期的 chat 才触发 LLM;授权是外部行为,进程不代授权。 -15. **业务白名单**:出站 Telegram chat 必须命中 `CHAT_ALLOWLIST`。 -16. **诊断面默认关闭**:`/debug/*` 需要 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子同时满足。 -17. **诊断面不进网关**:`/debug/*` 不在 Worker `SAFE_ROUTES`,只能直连后端 origin。 -18. **网关只透传、不决策**:Worker 不解析请求体、不校验业务逻辑、不下发 `Retry-After`。 -19. **AI 结果不重试**:LLM 分析失败即视为「不通知」,不重试、不降级为无 AI 通知。 -20. **无状态进程**:两个容器实例并发安全;无本地写。 -21. **无长轮询调度**:调度由外部 cron 触发 `POST /reconcile`。 -22. **AI 分析不持久化**:分析结果与授权状态之外的中间态不落盘。 -23. **JMAP capability 动态发现**:禁止硬编码 JMAP 能力列表,必须运行时读取。 +1. **JMAP read-only**: The Send class interface is not allowed, and any modification of the mailbox content is prohibited. +2. **No outbound sending**: `src/channel.rs` is the only Telegram outbound exit; direct sending of messages is prohibited at the domain layer. +3. **Long connections are prohibited**: WebSocket/SSE/long polling must not be introduced; all communications are short request-response. +4. **Stateless Recovery**: Do not use local files or memory states for recovery; only rely on replayable records in Redis after a crash. +5. **No database**: Relational or document databases are not allowed. +6. **No local writing**: The process does not write any files except the stdout log. +7. **The log only goes to stdout**: no log file is written, and no external log agent is connected. +8. **State only in Redis**: All mutable states only exist in externally managed Redis. +9. **Keys only come from environment variables**: Process configuration is not read from Redis. +10. **Keys are not echoed**: Any logs, error responses, and Debug output must not contain the key body. +11. **Key constant time comparison**: Admin credentials and admin session digest must be compared in constant time. +12. **Notifications only contain metadata**: Telegram notifications only contain sender/subject/time (+ number of attachments), and the text never enters the notification. +13. **AI results do not enter messages**: LLM output is only used for decision-making and does not enter Redis or Telegram messages. +14. **AI requires user-level external authorization**: Only chats that have been explicitly authorized and have not expired will trigger LLM; authorization is an external behavior and the process does not authorize on behalf of it. +15. **Business Whitelist**: Outbound Telegram chat must hit `CHAT_ALLOWLIST`. +16. **The diagnostic interface is closed by default**: `/debug/*` requires `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` to be satisfied at the same time. +17. **The diagnostic interface does not enter the gateway**: `/debug/*` is not in Worker `SAFE_ROUTES` and can only be directly connected to the backend origin. +18. **The gateway only transparently transmits and does not make decisions**: Worker does not parse the request body, does not verify the business logic, and does not issue `Retry-After`. +19. **No retry for AI results**: Failure of LLM analysis will be regarded as "no notification", no retry, and no downgrade to no AI notification. +20. **Stateless process**: Two container instances are safe to run concurrently; no local writes. +21. **No long polling scheduling**: Scheduling is triggered by external cron `POST /reconcile`. +22. **AI analysis is not persistent**: The analysis results and intermediate states other than the authorized state are not recorded. +23. **JMAP capability dynamic discovery**: Hard-coded JMAP capability list is prohibited and must be read at runtime. -## 4. 实现阶段(历史顺序,用于解释代码结构) +## 4. Implementation phase (historical sequence, used to explain code structure) -| 阶段 | 内容 | 稳定 ID | +| Stage | Content | Stable ID | |---|---|---| -| 0 | 配置 / 状态存储接口抽象,HTTP 骨架 | `ARCH-DEPS-STAGE0` | -| 1 | JMAP 只读 adapter | `ARCH-DEPS-STAGE1`, `MOD-JMAP-CLIENT` | -| 2 | Telegram 渠道自建客户端(不引入 bot 框架) | `ARCH-DEPS-STAGE4`, `MOD-TELEGRAM-NOTIFY` | -| 3 | AI 可选接入 | `REQ-AI-EXTERNAL-CONSENT`, `REQ-AI-FUSE` | -| 4 | Redis 落地 | `ARCH-DEPS-STAGE4` | -| 5 | 生产化(debug 面、网关、对账) | `MOD-DEBUG`, `SAF-DEBUG-GATE` | +| 0 | Configuration/state storage interface abstraction, HTTP skeleton | `ARCH-DEPS-STAGE0` | +| 1 | JMAP read-only adapter | `ARCH-DEPS-STAGE1`, `MOD-JMAP-CLIENT` | +| 2 | Telegram channel self-built client (without introducing bot framework) | `ARCH-DEPS-STAGE4`, `MOD-TELEGRAM-NOTIFY` | +| 3 | AI optional access | `REQ-AI-EXTERNAL-CONSENT`, `REQ-AI-FUSE` | +| 4 | Redis implementation | `ARCH-DEPS-STAGE4` | +| 5 | Production (debug surface, gateway, reconciliation) | `MOD-DEBUG`, `SAF-DEBUG-GATE` | -**阶段编号是历史顺序,不是当前状态声明。** 当前状态以 `reference.md` 与代码为准。 +**Phase numbers are historical order, not current status statements. ** The current status is subject to `reference.md` and code. -## 5. 禁止事项 +## 5. Prohibited matters -1. **不得引入未在本文件 §2 登记的依赖。** 新增依赖须先在本文件登记并说明理由。 -2. **不得为通过门禁而删除测试、跳过校验、放宽检查级别、删除访问控制。** -3. **不得在文档或注释中引用不存在的依赖、函数、类型、Redis 键或行号。** -4. **不得在代码中写「计划实现 X」类注释**;占位实现必须显式标注为占位。 -5. **不得在 SPA / 网关引入构建工具或框架**;`web/` 保持单文件 JS + 单文件 CSS。 -6. **不得让 SPA 持久化任何凭据**(无 `localStorage` / `sessionStorage` / Cookie 赋值)。 -7. **不得提交未验证的代码**;门禁结果必须来自本轮实际执行。 -8. **不得声称已通过真实环境验证,除非本轮真的连过。** -9. **不得在 `web/`、`cloudflare-worker/` 之外引入前端资源**。 -10. **不得让业务配置静默回退到环境变量。** +1. **Dependencies not registered in §2 of this document may not be introduced. ** New dependencies must be registered in this document first and the reasons given. +2. **Do not delete tests, skip verification, relax inspection levels, or delete access controls in order to pass the access control. ** +3. **Do not reference non-existent dependencies, functions, types, Redis keys or line numbers in documentation or comments. ** +4. **You are not allowed to write "plan to implement X" comments** in the code; placeholder implementations must be explicitly marked as placeholders. +5. **No build tools or frameworks may be introduced in SPA/Gateway**; `web/` maintains single-file JS + single-file CSS. +6. **The SPA must not be allowed to persist any credentials** (no `localStorage` / `sessionStorage` / Cookie assignments). +7. **Unverified code shall not be submitted**; the access control results must come from the actual execution of this round. +8. **Do not claim to have passed the real environment verification unless it is really connected this round. ** +9. **Front-end resources must not be introduced outside `web/` and `cloudflare-worker/`**. +10. **Business configuration must not be allowed to silently fall back to environment variables. ** -## 6. 测试验收与门禁 +## 6. Test acceptance and access control -### 6.1 变更验证闭环 +### 6.1 Change verification closed loop -任何涉及运行时行为或可核对事实的改动,**同一改动内**完成:实现 → 测试 → 文档。 +Any changes involving runtime behavior or verifiable facts are done within the same change: Implementation → Testing → Documentation. -不得只改代码不改文档,也不得只改文档不改代码。 +You must not change the code without changing the documentation, and you must not change the documentation without changing the code. -### 6.2 GATE-P0(代码门禁) +### 6.2 GATE-P0 (code access control) -Debian 最小发行版容器内执行,容器内必须显式 `export PATH=/usr/local/cargo/bin:$PATH`: +Executed in the Debian minimum release container, the container must explicitly `export PATH=/usr/local/cargo/bin:$PATH`: ```bash cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ @@ -116,174 +118,174 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:93 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测后复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 +**Current baseline: 93 passed / 0 failed / 4 ignored** (Add 4 new cursor parsing and walk paging and retest after single test; ignored: `real_server_tests::session_list_and_read_smoke` and `debug::tests::debug_config_reports_timezone_of_business_configured_app` require external real JMAP Server credentials, `real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` and `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` require `REDIS_TEST_URL`). -**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 +**Note**: The bash `-lc` script of `docker run` must be wrapped in **single quotes**. Using double quotes will first expand `$PWD` / `$PATH` on the host, and cargo will not be found in the container. -### 6.3 GATE-DOCS(文档门禁) +### 6.3 GATE-DOCS (Document Access Control) ```bash cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh ``` -6 个校验器全过、exit 0 才算通过。只读,从不写文件。 +Pass all 6 validators and exit 0. Read only, never write files. -新增 `check_file_size`:`src/`、`web/`、`cloudflare-worker/src/` 下超过 500 行的源文件必须带 -`SPLIT-EVAL:` 标记(AGENTS.md §2.1)。它证明标记存在,不证明写下的拆分理由站得住脚。 +Added `check_file_size`: Source files with more than 500 lines under `src/`, `web/`, `cloudflare-worker/src/` must have +`SPLIT-EVAL:` tag (AGENTS.md §2.1). It proves that the mark exists, not that the written reasons for the split are tenable. -新增 `audit_paths`:`docs/*.md` 与 `AGENTS.md` 里反引号内的路径型 token 必须真实存在。 -它存在的原因是:文档曾出现从未创建的路径,而虚构路径读起来和真实路径完全一样,读者无法分辨 -「我们决定不做」和「这写错了」。`docs/retired.md` 同样在检查范围内——废弃登记里的路径也必须真实, -未落地的形态只以文字描述。豁免只有三类,都在脚本里可见:指向外部仓库的所有者前缀 -(`stalwartlabs/...`)、git 历史可证已删除的文件、以及一份注明理由的知名文件名白名单。 -它不检查反引号外的散文,裸文件名也只按同名校验解析。 +Added `audit_paths`: The path tokens in backticks in `docs/*.md` and `AGENTS.md` must actually exist. +The reason for its existence is that the document once appeared with a path that was never created, and the fictitious path read exactly like the real path, and the reader could not tell the difference. +"We decided not to do it" and "This is wrongly written". `docs/retired.md` is also within the scope of the check - the path in the abandonment registration must also be true, +The ungrounded form is only described in words. There are only three types of exemptions, all visible in the script: Owner prefix pointing to external repository +(`stalwartlabs/...`), git history to document deleted files, and a justified whitelist of well-known filenames. +It does not check the prose outside the backticks, and the bare file name is only parsed according to the same name verification. -**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。 -**它不证明语义正确。** 报告校验结果时必须说明这个区分。 +**`0 error` only proves "reachability"**: the line number exists, the reference can be resolved, the number of table columns is consistent, and the mark is in place. +**It does not prove semantically correct. ** This distinction must be stated when reporting verification results. -### 6.4 前端测试 +### 6.4 Front-end testing ```bash cd /home/okabe/Repo/messageweave/web && node --test *.test.mjs cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js ``` -### 6.5 不变量必须有断言 +### 6.5 Invariants must have assertions -§3 的每条安全不变量都要有一条测试或代码级断言能证明它成立, -而不是只写在文档里。纯重构不得改变测试总数。 +Each safety invariant in §3 must have a test or code-level assertion that proves it holds. +Rather than just writing it in the document. Pure refactoring must not change the total number of tests. -### 6.6 外部依赖测试 +### 6.6 External dependency testing -Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向 -均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis -已连接。真实环境集成测试必须用 `#[ignore]` 标注,缺凭据时静默跳过(不泄露凭据、不判失败)。 +The business credentials of Stalwart and Telegram have been passed through real machine joint debugging - outbound, query and **inbound callback** directions +All have been verified (the registration, callback, and verification of `setWebhook` and `PushSubscription` are all passed), hosted Redis +Connected. Real environment integration tests must be marked with `#[ignore]` and will be silently skipped if credentials are missing (credentials will not be leaked and failure will not be judged). -**环境阻塞项:无。** 最后两项也已验证:TTL 数值在真实 Upstash 实例上实测(`SET … NX EX` / `SET … EX 3600` / Lua `EXPIRE 86400` 三处原子写入点,连跑 6 轮全绿),`Email/changes` 的 `newState` 续传在真实 Stalwart 环境触发过(见 docs/design.md §10.5)。[`opengaps.md`](opengaps.md)「阻塞」区当前为空。 +**Environmental blocking items: None. ** The last two items have also been verified: the TTL value was measured on a real Upstash instance (`SET … NX EX` / `SET … EX 3600` / Lua `EXPIRE 86400` three atomic write points, running for 6 consecutive rounds, all green), `newState` of `Email/changes` was triggered in a real Stalwart environment (see docs/design.md §10.5). [`opengaps.md`](opengaps.md) The "blocking" area is currently empty. -## 7. 文档边界 +## 7. Document boundaries -每个文档只回答一个问题;**不得跨文档复制表格、清单或数值。** +Answer only one question per document; **Tables, lists, or values ​​may not be copied across documents. ** -| 文档 | 回答什么 | 权威范围 | +| Documentation | What to answer | Scope of authority | |---|---|---| -| [`../AGENTS.md`](../AGENTS.md) | 通用、语言无关的代码编写与环境构建规范 | 质量规则 | -| `README.md` / `README.zh-CN.md` | 面向使用者:是什么、怎么跑、怎么配置 | 用户可见事实(中英必须信息对等) | -| `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 | -| `docs/deployment.md` | 怎么部署:Dockerfile、secrets、网关、多实例、cron | 部署与运维 | -| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | -| `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 | -| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | -| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 | -| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) | -| `cloudflare-worker/README.md` | 网关自身的配置与语义 | 网关 | +| [`../AGENTS.md`](../AGENTS.md) | Universal, language-independent code writing and environment building specifications | Quality rules | +| `README.md` / `README.zh-CN.md` | User-oriented: what is it, how to run it, how to configure it | User-visible facts (information must be equivalent in Chinese and English) | +| `docs/design.md` | Why it is designed this way: data flow, module boundaries, state machines, error handling | Architectural intent | +| `docs/deployment.md` | How to deploy: Dockerfile, secrets, gateway, multiple instances, cron | Deployment and operation and maintenance | +| `docs/reference.md` | The single authoritative source of verifiable facts: routing, Redis keys and TTLs, configuration items, error codes, outbound constants | **verifiable facts** | +| `docs/opengaps.md` | Gaps, Blockages, Next Stage Goals | Open Items | +| `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions | +| `docs/charter.md` | This document: project constraints, technology selection, implementation stage, security invariants, stable ID registry | Project constraints | +| `web/` (no documentation, 4 files) | Manage SPA sources: static configuration pages and front-end logic, covered by `web/config.test.mjs` | Front-end behavior (authoritative facts documented in `docs/design.md` and `docs/reference.md`) | +| `cloudflare-worker/README.md` | Configuration and semantics of the gateway itself | Gateway | -**跨文档引用规则**: +**Cross-document citation rules**: -- 跨文档**禁止**用 `§x.y` 章节号(章节号随编辑漂移);用稳定 ID。 -- **同文档内**允许 `§x.y`。 -- 新增稳定 ID 必须先登记到 §8 注册表;未登记 ID 不得出现在注释、文档或标识符中。 -- **改公共 API / Redis 键与 TTL / 配置项 / 错误码 / 用户可见文案时,同轮同步全部相关文档。** -- **新增文档必须加入 `scripts/docs_check/` 各校验器的文档清单**,否则它永远不会被校验。 +- Disable the use of `§x.y` chapter numbers across documents (chapter numbers drift with editing); use stable IDs. +- **Within the same document** `§x.y` is allowed. +- New stable IDs must first be registered in the §8 registry; unregistered IDs may not appear in comments, documents, or identifiers. +- **When changing the public API/Redis key and TTL/configuration items/error codes/user-visible copy, all related documents will be synchronized in the same round. ** +- **Newly added documents must be added to the `scripts/docs_check/` document list of each validator**, otherwise it will never be verified. -## 8. 稳定 ID 注册表 +## 8. Stable ID registry -**唯一权威索引表。** 跨文档引用一律用稳定 ID(不用 `§x.y` 章节号)。 +**The only authoritative index table. ** Always use stable IDs for cross-document references (no `§x.y` section numbers). -- ID 语义稳定、全仓唯一、可 grep;本表登记每个 ID 的**定义文件**与一句话说明。 -- 新增约束 / 组件时,先在本表登记一行,再在定义点写 `**ID**` 标签。 -- 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。 -- 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。 +- ID is semantically stable, unique across the warehouse, and grepable; this table registers the **definition file** and one-sentence description of each ID. +- When adding a new constraint/component, first register a row in this table, and then write the `**ID**` tag at the definition point. +- New IDs must be registered before appearing in comments, documents or identifiers; the validator will reject unregistered IDs. +- When the content is moved, only the "Definition File" column in this table will be updated, and all references will be unchanged. -共 85 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)· -`FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)· -`BOUND-*`(边界)· `ARCH-*`(架构)。 +85 IDs in total, grouped by prefix: `C-*` (deployment constraints) · `NG-*` (non-target) · `MOD-*` (module) · +`FLOW-*` (data flow) · `REQ-*` (requirement) · `SAF-*` (security) · `NFR-*` (non-functional) · `GATE-*` (gate control) · +`BOUND-*` (boundary) · `ARCH-*` (architecture). -| ID | 定义文件 | 一句话 | 类别 | +| ID | Definition file | One sentence | Category | |---|---|---|---| -| `C-DOCKER` | docs/deployment.md §0 | 必须 Docker 部署 | 部署约束 | -| `C-DEBIAN-SLIM` | docs/deployment.md §0 | Debian slim,禁 Alpine | 部署约束 | -| `C-NO-SECRET-IN-IMAGE` | docs/deployment.md §0 | secrets 不进镜像 | 部署约束 | -| `C-RUSTLS` | docs/deployment.md §0 | rustls + native-roots | 部署约束 | -| `C-HTTPS-INBOUND` | docs/deployment.md §0 | HTTPS-only 入站,容器内明文 HTTP | 部署约束 | -| `C-HTTPS-URL` | docs/deployment.md §0 | 公网 HTTPS URL 由平台提供(bot 不持证书) | 部署约束 | -| `C-AUTH-APP-BASIC` | docs/deployment.md §9 / docs/design.md §3.1 | Stalwart 认证 = App Password + Basic | 部署约束 | -| `C-NO-TCP-EXPOSE` | docs/deployment.md §0 | 单监听 `PORT`,不暴露附加 TCP 端口 | 部署约束 | -| `C-NO-LONG-CONN` | docs/deployment.md §0 | 无 SSE/WS/长轮询等长连接 | 部署约束 | -| `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 | -| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 | -| `C-PORT` | docs/deployment.md §0 | 通用 PORT 约定 | 部署约束 | -| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` 常驻,非目标(该变量已随 `Config::from_env()` 删除,代码中已无此标识符) | 非目标 | -| `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE 长连接,非目标 | 非目标 | -| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 | -| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 | -| `NG-LOCAL-VOLUME` | docs/deployment.md §1 | 本地卷持久化,非目标 | 非目标 | -| `NG-SERVERLESS-BIND` | docs/deployment.md §1 | 绑定具体 serverless 平台,非目标 | 非目标 | -| `MOD-DEDUP` | docs/deployment.md §6 | Redis `SET NX` 幂等键 | 组件 | -| `MOD-STREAMS` | docs/deployment.md §6 | Redis Streams 队列 + worker | 组件 | -| `MOD-SINCESTATE` | docs/deployment.md §6 | sinceState 游标(存 Redis) | 组件 | -| `FLOW-NEW-MAIL` | docs/design.md §5.4 / docs/deployment.md §6 | Push 新邮件流 | 数据流 | -| `FLOW-RECONCILE` | docs/deployment.md §6 | 外部 Cron 对账补差 + Redis 丢失恢复 | 数据流 | -| `REQ-AI-CONFIRM` | docs/design.md §12 | AI 仅在明确要求+确认后接触正文 | 需求 | -| `REQ-VIEW-DIRECT` | docs/design.md §12 | 查看原文始终 JMAP 直取 | 需求 | -| `REQ-LONG-EMAIL` | docs/design.md §12 | 长邮件禁止发全文,AI 摘要 ~300 字 | 需求 | -| `REQ-ANALYSIS-EPHEMERAL` | docs/design.md §12 | 分析/摘要结果不持久化 | 需求 | -| `REQ-ATTACH-ONDEMAND` | docs/design.md §12 | 附件按需拉取 | 需求 | -| `REQ-AI-FUSE` | docs/design.md §12 | AI 失败约 3 次 → 熔断 + 用户确认回退 | 需求 | -| `REQ-LLM-OPENAI-COMPAT` | docs/design.md §12 | OpenAI-compatible 环境变量 | 需求 | -| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 | -| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | -| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | -| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | -| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | -| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 | -| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | -| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | -| `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 | -| `SAF-CHAT-ALLOWLIST` | §3 / docs/design.md §7.3 | CHAT_ALLOWLIST 硬约束,处理前先拒绝非白名单 | 安全 | -| `SAF-AUTH-RECONCILE` | §3 / docs/design.md §7.3 | `/reconcile` 需 `Authorization: Bearer RECONCILE_TOKEN`,fail-closed | 安全 | -| `SAF-AUTH-TG-WEBHOOK` | §3 / docs/design.md §7.3 | `/webhook/tg` 需头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET` | 安全 | -| `SAF-AUTH-JMAP-PUSH` | §3 / docs/design.md §7.3 | `/push/jmap` 按 `pushSubscriptionId` 查 Redis 短期验证状态(存 `session_digest` 摘要),缺失或摘要不符拒绝;状态缺失回落 worker 重新验证(fail-closed) | 安全 | -| `SAF-ADMIN-SESSION` | src/config.rs `session_digest` / §3 | 返回用于 Redis admin-session 记录的 SHA-256 摘要;bearer token 本体绝不写入 Redis | 安全 | -| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 | -| `SAF-PROBE-PUBLIC` | §3 / docs/design.md §7.3 | `/healthz`、`/ready` 公开探针:无鉴权、无敏感信息 | 安全 | -| `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness(进程存活),语义长期稳定 | 架构 | -| `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready`+`probe_jmap_session`+`probe_telegram_get_me` | `/ready` 端到端就绪:配置完整性 + Redis 可达性 + **出站只读探测**(JMAP session `GET`、Telegram `getMe`,各 `PROBE_TIMEOUT`=3000ms、并行,最坏约 3s),四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`);探针只读、无状态写入(`refresh_business_config` 仅读 Redis),bot token 仅用于拼 URL | 架构 | -| `GATE-P0` | docs/design.md §10.0 | 阶段0 P0 门禁(fmt/clippy/test 过 Debian 容器等 8 项) | 流程 | -| `BOUND-STAGE1` | docs/design.md §10.0 | 阶段1 推进边界(过 GATE-P0 才进;R1 入口鉴权已在阶段0 落地) | 流程 | -| `ARCH-CONFIG-ENV` | docs/design.md §7.1 | 配置=环境变量手工解析,无 figment/TOML | 架构 | -| `ARCH-AXUM-08` | docs/design.md §10.0 | 单端口 axum 0.8 入口(版本以 Cargo.toml 为准) | 架构 | -| `ARCH-STAGE0` | docs/design.md §10.0 / §5.3 | 阶段0 实际交付与现状 | 架构 | -| `ARCH-DEPS-STAGE0` | docs/design.md §10.0 / §4 | 阶段0 实际依赖集(axum/serde/secrecy/subtle/tokio/tracing…) | 架构 | -| `ARCH-DEPS-STAGE1` | docs/design.md §10.0-1 / §4 | 阶段1 依赖现况:`jmap-client 0.4.2` 已引入,版本/features 以 Cargo.toml 为准,禁用 WebSocket feature | 架构 | -| `ARCH-DEPS-STAGE4` | docs/design.md §10.0-1 / §4 | 阶段3.5/4 后依赖现况:`redis 0.27`、`reqwest 0.13` 已引入并实际使用;`teloxide` **未**引入(Telegram 由 `src/channel.rs` 用 reqwest 自研实现);版本一律以 `Cargo.toml` 为准 | 架构 | -| `MOD-JMAP-CLIENT` | docs/design.md §6-7 / §4 | `domain::jmap::client` 真实只读 adapter(session/account/mailbox/email 只读) | 组件 | -| `MOD-TELEGRAM-NOTIFY` | src/worker.rs / docs/design.md §12 | 有界元数据通知 worker:消费出站队列,把脱敏通知经 Telegram 投递(不承载正文/AI 响应) | 组件 | -| `REQ-JMAP-SESSION-URL` | docs/design.md §7.1 / .env.example | `JMAP_SESSION_URL` 接受服务基地址或完整 /.well-known/jmap,归一化为 origin/base 后再交 jmap-client(无重复路径);**代码已实现(D-G1-1),已在真实账号真机验证**(2026-09-28:`fetchChanges` 持久化 `baseline:` 游标、`Email/query` 搜索回帖,均经用户确认) | 需求 | -| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 | -| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 | -| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 | -| `ARCH-LB-WORKER` | docs/deployment.md §10 | 多实例 LB/HA:免费 Cloudflare Worker 作唯一对外入口 + 故障转移,后端为多平台同镜像 | 架构 | -| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron 只登记 Worker 的稳定 URL;后端平台入口不对外登记 | 约束 | -| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | 多实例必须共享同一组 `SAF-AUTH-*` secret,否则随机 401 | 约束 | -| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | 信任模型=透传:Worker 不改写鉴权;后端必须继续 fail-closed 校验(后端可能被公网直连) | 安全 | -| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | -| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | -| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | -| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | -| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | -| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | -| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | -| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 18 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | -| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | -| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | -| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | -| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | -| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 | -| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 | -| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 | -| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 | -| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 | -| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 | -| `GATE-UPTIME-KUMA` | docs/deployment.md §10.1 | `/healthz` 稳定语义可直接接 Uptime Kuma 等外部探针 | 门禁 | -| `GATE-DOCS` | scripts/docs_check/run_all.sh / docs/deployment.md | 文档门禁:校验器全部 0 error、exit 0 才算通过 | 门禁 | +| `C-DOCKER` | docs/deployment.md §0 | Docker deployment required | Deployment constraints | +| `C-DEBIAN-SLIM` | docs/deployment.md §0 | Debian slim, disable Alpine | Deployment constraints | +| `C-NO-SECRET-IN-IMAGE` | docs/deployment.md §0 | secrets are not included in the image | Deployment constraints | +| `C-RUSTLS` | docs/deployment.md §0 | rustls + native-roots | Deployment constraints | +| `C-HTTPS-INBOUND` | docs/deployment.md §0 | HTTPS-only inbound, plaintext HTTP within the container | Deployment constraints | +| `C-HTTPS-URL` | docs/deployment.md §0 | Public HTTPS URL is provided by the platform (bot does not hold a certificate) | Deployment constraints | +| `C-AUTH-APP-BASIC` | docs/deployment.md §9 / docs/design.md §3.1 | Stalwart Authentication = App Password + Basic | Deployment Constraints | +| `C-NO-TCP-EXPOSE` | docs/deployment.md §0 | Single listener `PORT`, no additional TCP ports exposed | Deployment constraints | +| `C-NO-LONG-CONN` | docs/deployment.md §0 | No SSE/WS/long-polling equal-length connections | Deployment constraints | +| `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | State only external Redis, not SQLite/local volumes | Deployment constraints | +| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis user hosting + enable AOF persistence | Deployment constraints | +| `C-PORT` | docs/deployment.md §0 | Common PORT conventions | Deployment constraints | +| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` resident, non-target (this variable has been removed with `Config::from_env()`, this identifier is no longer in the code) | non-target | +| `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE long-lived, non-target | non-target | +| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram long polling, non-target | non-target | +| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite persistence, non-target | non-target | +| `NG-LOCAL-VOLUME` | docs/deployment.md §1 | Local volume persistence, non-target | non-target | +| `NG-SERVERLESS-BIND` | docs/deployment.md §1 | Bind to specific serverless platform, non-target | non-target | +| `MOD-DEDUP` | docs/deployment.md §6 | Redis `SET NX` idempotent key | Components | +| `MOD-STREAMS` | docs/deployment.md §6 | Redis Streams queue + worker | Components | +| `MOD-SINCESTATE` | docs/deployment.md §6 | sinceState cursor (stored in Redis) | Components | +| `FLOW-NEW-MAIL` | docs/design.md §5.4 / docs/deployment.md §6 | Push new mail flow | Data flow | +| `FLOW-RECONCILE` | docs/deployment.md §6 | External Cron reconciliation + Redis loss recovery | Data flow | +| `REQ-AI-CONFIRM` | docs/design.md §12 | AI only touches text after explicit request + confirmation | Requirements | +| `REQ-VIEW-DIRECT` | docs/design.md §12 | View original text always JMAP direct fetch | Requirements | +| `REQ-LONG-EMAIL` | docs/design.md §12 | It is forbidden to send the full text of long emails, AI abstract ~300 words | Requirements | +| `REQ-ANALYSIS-EPHEMERAL` | docs/design.md §12 | Analysis/summary results are not persisted | Requirements | +| `REQ-ATTACH-ONDEMAND` | docs/design.md §12 | Pull attachments on demand | Requirements | +| `REQ-AI-FUSE` | docs/design.md §12 | AI failed about 3 times → circuit breaker + user confirmation fallback | Requirements | +| `REQ-LLM-OPENAI-COMPAT` | docs/design.md §12 | OpenAI-compatible environment variables | Requirements | +| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | Only send text to external AI with explicit permission from the user | Requirements | +| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI authorization period and Redis short-term TTL (authorization validity period, reask after expiration) | Requirements | +| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | Single account; multiple accounts = multiple bot instances | Requirements | +| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 Comments | `PushSubscription/set` create does not have the `types` parameter in jmap-client 0.4.2; the subscription id must be narrowed to `Email` + `EmailDelivery` by `push_subscription_update_types` before being exposed to the outside world | Requirements | +| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP reconciliation cursor is only advanced after all paging events are successfully enqueued (XADD); a single reconciliation is guaranteed to fly solo (TTL) by Redis SET NX EX lock `lock:reconcile` 300s, owner token is renewed for 90s, only the holder can renew/release); the processing end then goes through `claim_dedup` (SET NX EX, 86400s) to ensure that the same flow message is not delivered repeatedly | Requirements | +| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | Notification receipt time is configured by business `timezone` (IANA, default `Asia/Shanghai`) is rendered as `%Y-%m-%d %H:%M`; only 16 non-daylight saving time zones are accepted, no match is returned 422, no time zone library inference | Requirements | +| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | Notification availability ≥99.9%, minor latency allowed | Non-functional | +| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | External cron reconciliation interval 5–10 minutes | Non-functional | +| `SAF-NOTIFY-META` | §3 | New email notifications only contain metadata, and the text is not included in the notification | Security | +| `SAF-CHAT-ALLOWLIST` | §3/docs/design.md §7.3 | CHAT_ALLOWLIST hard constraint, reject non-whitelist before processing | Security | +| `SAF-AUTH-RECONCILE` | §3/docs/design.md §7.3 | `/reconcile` requires `Authorization: Bearer RECONCILE_TOKEN`, fail-closed | Security | +| `SAF-AUTH-TG-WEBHOOK` | §3/docs/design.md §7.3 | `/webhook/tg` requires header `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET` | Security | +| `SAF-AUTH-JMAP-PUSH` | §3 / docs/design.md §7.3 | `/push/jmap` Press `pushSubscriptionId` to check the Redis short-term verification status (save `session_digest` digest), if it is missing or the digest does not match, it will be rejected; if the status is missing, it will fall back to worker re-verification (fail-closed) | Security | +| `SAF-ADMIN-SESSION` | src/config.rs `session_digest` / §3 | Returns the SHA-256 digest used for Redis admin-session records; the bearer token itself is never written to Redis | Security | +| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` fields and the public configuration API | The secret field does not implement `Debug` and never enters the public configuration API response; the Redis wire format type is private and must not be serialized into the HTTP response | Security | +| `SAF-PROBE-PUBLIC` | §3 / docs/design.md §7.3 | `/healthz`, `/ready` Public probes: no authentication, no sensitive information | Security | +| `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness (process survival), long-term semantic stability | Architecture | +| `ARCH-READY-BASELINE` | docs/design.md §7.3/src/notify.rs `ready`+`probe_jmap_session`+`probe_telegram_get_me` | `/ready` End-to-end ready: configuration integrity + Redis reachability + **outbound read-only probe** (JMAP session `GET`, Telegram `getMe`, each `PROBE_TIMEOUT`=3000ms, parallel, worst about 3s), all four exceed `200` (readiness report JSON contains real `jmap`/`telegram` fields), any one fails `503` (standard error envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`); the probe is read-only, stateless write (`refresh_business_config` is only read from Redis), the bot token is only used to spell URL | Schema | +| `GATE-P0` | docs/design.md §10.0 | Stage 0 P0 gate control (fmt/clippy/test passed Debian container, etc. 8 items) | Process | +| `BOUND-STAGE1` | docs/design.md §10.0 | Stage 1 Pushing the Boundary (Enter after passing GATE-P0; R1 entrance authentication has been implemented in Stage 0) | Process | +| `ARCH-CONFIG-ENV` | docs/design.md §7.1 | Configuration = environment variables manually parsed, no figment/TOML | Architecture | +| `ARCH-AXUM-08` | docs/design.md §10.0 | Single-port axum 0.8 entry (version is subject to Cargo.toml) | Architecture | +| `ARCH-STAGE0` | docs/design.md §10.0 / §5.3 | Phase 0 Actual Delivery and Current Status | Architecture | +| `ARCH-DEPS-STAGE0` | docs/design.md §10.0 / §4 | Stage 0 actual dependency set (axum/serde/secrecy/subtle/tokio/tracing…) | Architecture | +| `ARCH-DEPS-STAGE1` | docs/design.md §10.0-1 / §4 | Phase 1 dependency status: `jmap-client 0.4.2` has been introduced, version/features shall be subject to Cargo.toml, WebSocket feature is disabled | Architecture | +| `ARCH-DEPS-STAGE4` | docs/design.md §10.0-1 / §4 | Current dependency status after stage 3.5/4: `redis 0.27`, `reqwest 0.13` have been introduced and actually used; `teloxide` has not been introduced (Telegram is self-developed by `src/channel.rs` and implemented by reqwest); the version must be `Cargo.toml` Subject | Architecture | +| `MOD-JMAP-CLIENT` | docs/design.md §6-7 / §4 | `domain::jmap::client` true read-only adapter (session/account/mailbox/email read-only) | Components | +| `MOD-TELEGRAM-NOTIFY` | src/worker.rs / docs/design.md §12 | Bounded metadata notification worker: consume outbound queue, deliver masked notification via Telegram (does not carry body/AI response) | Components | +| `REQ-JMAP-SESSION-URL` | docs/design.md §7.1 / .env.example | `JMAP_SESSION_URL` accepts the service base address or complete /.well-known/jmap, normalizes it to origin/base and then submits it jmap-client (no duplicate paths); **The code has been implemented (D-G1-1) and has been verified on a real machine with a real account** (2026-09-28: `fetchChanges` persistence `baseline:` cursor, `Email/query` search replies, all confirmed by the user) | Requirements | +| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL constraints: HTTPS only, disallow inline credentials, deny dangerous query | Security | +| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` Multi-part Original text: Splice the parts "with part_id and bodyValue" in text_body order; no available part → clear error | Requirements | +| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 access control: read-only adapter **Code has been implemented** (mock + `#[ignore]` real machine test), **to be real `cargo test -- --ignored jmap::` verification** | Process | +| `ARCH-LB-WORKER` | docs/deployment.md §10 | Multi-instance LB/HA: Free Cloudflare Worker as the only external entrance + failover, the backend is multi-platform with the same mirror | Architecture | +| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron only registers the stable URL of the Worker; the backend platform entrance does not register externally | Constraints | +| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | Multiple instances must share the same set of `SAF-AUTH-*` secrets, otherwise a random 401 | Constraints | +| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | Trust model = transparent transmission: Worker does not overwrite authentication; the backend must continue fail-closed verification (the backend may be directly connected to the public network) | Security | +| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` does not fan out, Redis lock guarantees single instance execution and avoids repeated reconciliation | Security | +| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | Multiple instances use the same Streams consumer group name, and Redis will automatically allocate it (at-least-once will not repeat processing) | Components | +| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker aggregate health view, reporting the survival of each backend for external monitoring | Components | +| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | Remote joint debugging read-only surface: `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounted after double factor is enabled `/debug/*`, otherwise it will not be mounted | component | +| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | Two-factor gate: "`DEBUG_ENABLED` is true or command line with `--debug`" **and** `DEBUG_TOKEN` Routes are mounted only if they are not empty; if any are missing, they are not mounted at all (the request fails with `404`), and it is absolutely closed by default. The start signal goes through env instead of argv, so that the startup command remains static, and the switch can be switched at a single point on the platform console | Security | +| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | After mounting `/debug/*` requires `Authorization: Bearer DEBUG_TOKEN` constant time comparison, fails with `401` and has no side effects | Security | +| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | Endpoint contracts: `GET /debug/ping`, `/config`, `/redis`, `/jmap`, `/telegram`, `/worker` are all read-only; `POST /debug/notify` Send a test message through the real outbound link; the response body does not contain the original text of secret (the credential field only returns `*_configured` Boolean, and the non-ciphertext identity and budget fields are still returned in plain text) | Requirements | +| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` is not among the 18 safe routes of the gateway, and the Worker will always receive `404 route not forwarded`; it can only be directly connected to the backend origin, and the public network is unreachable | Security | +| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` only verifies `chat_id` when the chat whitelist** is not empty**; it will not intercept when the whitelist is not configured (empty), so to enable this page, you must confirm that the business whitelist has been configured | Security | +| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | Multi-instance high availability semantics; both active-active or active-standby; Redis single point of failure is not within the scope of the solution (user external solution) | Non-functional | +| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | Production does not use any database (no SQLite/Postgres/MySQL/embedded), Redis is the only state store; the application does not connect to a second database | Constraints | +| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | Disable local file/directory writes (log/data/tempcache/local volumes) | Constraints | +| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | The log only writes stdout/stderr, collected by the platform; disables the file log backend | Constraints | +| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | Log and Redis write content is limited to structured events/counts/timestamps/masked summaries; keys/email bodies/AI request responses/attachment content are prohibited | Security | +| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | The global switch is Redis single key `config:enabled`; if it is not written, it is regarded as closed, and `business_enabled` will be treated as closed if an error occurs (fail-closed); writing requires admin-session Bearer | Security | +| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | It is prohibited to rely on in-process status for production recovery; all recovery will use Redis + JMAP reconciliation; in-process caching is only for performance optimization, and loss must be safe and reentrant | Constraints | +| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis is the only state source | The state layer uses Redis (Streams/SET NX/Lock/Abstract), and the process does not hold recoverable state | Architecture | +| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis is provided by an external certified instance and is not in the same container as this service | Deployment constraints | +| `GATE-UPTIME-KUMA` | docs/deployment.md §10.1 | `/healthz` stable semantics can be directly connected to external probes such as Uptime Kuma | Access Control | +| `GATE-DOCS` | scripts/docs_check/run_all.sh / docs/deployment.md | Document access control: All validators must have 0 error and exit 0 to be considered passed | Access control | diff --git a/docs/charter.zh-CN.md b/docs/charter.zh-CN.md new file mode 100644 index 0000000..d2c6394 --- /dev/null +++ b/docs/charter.zh-CN.md @@ -0,0 +1,291 @@ +# MessageWeave — 项目章程 + +> [English version / 英文版 → charter.md](charter.md) + +> 本文件是**项目专属**规范:项目目标、技术选型、安全不变量、实现阶段、禁止事项、 +> 测试验收、文档边界与**稳定 ID 注册表**。 +> +> 通用、语言无关的代码编写与环境构建规范见 [`../AGENTS.md`](../AGENTS.md); +> 可核对事实(路由 / Redis 键与 TTL / 配置项 / 错误码 / 出站常量)的唯一权威来源见 +> [`reference.md`](reference.zh-CN.md);缺口与阻塞见 [`opengaps.md`](opengaps.zh-CN.md)。 +> +> 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** + +> **代码基线** `fc686ab`(src/ 行号锚点)|文档以当前 `main` 为准,锚点须随代码基线复核。 + +--- + +## 1. Project goals + +一个**部署在 Docker + 外部托管 Redis** 的 JMAP 邮件通知服务: + +- 通过 JMAP 只读拉取邮件变更,判断是否需要通知。 +- 可选的 LLM 分析(AI 开关 + 用户级外部授权),分析结果**不存储、不进入消息**。 +- 通过 Telegram 向**白名单 chat** 推送**仅含元数据**的通知。 +- 用户通过 Telegram 命令(`/summary`、`/search`)与 `/help` 交互。 +- 通过浏览器 SPA 配置业务参数;通过受保护接口开关业务处理。 + +**不做**:多租户、账号体系、Web UI 之外的客户端、消息持久化、邮件双向同步。 + +## 2. Technology selection (locked) + +| 层 | 选型 | 版本 | +|---|---|---| +| 语言 | Rust | edition 2021 | +| Web 框架 | axum | 0.8 | +| 异步 | tokio | 1 | +| 状态存储 | Redis | redis 0.27 | +| JMAP 客户端 | jmap-client | 0.4.2(`default-features = false` + features `["async","rustls"]`) | +| Telegram | 自建轻量客户端(`src/channel.rs`),**不引入** bot 框架 | — | +| HTTPS 客户端 | reqwest | 0.13 | +| 密钥类型 | secrecy | 0.10 | +| 加密 | ring | 0.17 | +| 日志 | tracing + tracing-subscriber | 0.1 / 0.3 | +| 调度 | 外部 cron → `POST /reconcile` | — | +| 前端 | 原生 HTML + 单文件 JS + 单文件 CSS,**无构建工具、无框架** | — | +| 网关 | Cloudflare Worker(纯 JS,零依赖) | — | + +## 3. Security boundary (cannot be violated) + +23 条,编号 `SAF-*` / `REQ-*` / `C-*`,全部登记在 §8 注册表。 + +1. **JMAP 只读**:不得使用 Send 类接口,禁止修改邮箱任何内容。 +2. **不出站发信**:`src/channel.rs` 是唯一 Telegram 出站出口;领域层禁止直接发信。 +3. **禁止长连接**:不得引入 WebSocket / SSE / 长轮询;所有通信为短请求-响应。 +4. **无状态恢复**:不得用本地文件或内存状态做恢复;崩溃后仅依赖 Redis 中的可重放记录。 +5. **无数据库**:不得引入关系型或文档型数据库。 +6. **无本地写**:除 stdout 日志外,进程不写任何文件。 +7. **日志只走 stdout**:不写日志文件、不接外部日志 agent。 +8. **状态只在 Redis**:一切可变状态只存在于外部托管 Redis。 +9. **密钥只来自环境变量**:进程配置不从 Redis 读取。 +10. **密钥不回显**:任何日志、错误响应、Debug 输出都不得包含密钥本体。 +11. **密钥常量时间比较**:管理凭据与 admin session 摘要必须常数时间比对。 +12. **通知只含元数据**:Telegram 通知只含发件人 / 主题 / 时间(+ 附件数),正文绝不进入通知。 +13. **AI 结果不进入消息**:LLM 输出只用于决策,不落 Redis、不进入 Telegram 消息。 +14. **AI 需用户级外部授权**:只有显式授权过且未过期的 chat 才触发 LLM;授权是外部行为,进程不代授权。 +15. **业务白名单**:出站 Telegram chat 必须命中 `CHAT_ALLOWLIST`。 +16. **诊断面默认关闭**:`/debug/*` 需要 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子同时满足。 +17. **诊断面不进网关**:`/debug/*` 不在 Worker `SAFE_ROUTES`,只能直连后端 origin。 +18. **网关只透传、不决策**:Worker 不解析请求体、不校验业务逻辑、不下发 `Retry-After`。 +19. **AI 结果不重试**:LLM 分析失败即视为「不通知」,不重试、不降级为无 AI 通知。 +20. **无状态进程**:两个容器实例并发安全;无本地写。 +21. **无长轮询调度**:调度由外部 cron 触发 `POST /reconcile`。 +22. **AI 分析不持久化**:分析结果与授权状态之外的中间态不落盘。 +23. **JMAP capability 动态发现**:禁止硬编码 JMAP 能力列表,必须运行时读取。 + +## 4. Implementation phase (historical sequence, used to explain code structure) + +| 阶段 | 内容 | 稳定 ID | +|---|---|---| +| 0 | 配置 / 状态存储接口抽象,HTTP 骨架 | `ARCH-DEPS-STAGE0` | +| 1 | JMAP 只读 adapter | `ARCH-DEPS-STAGE1`, `MOD-JMAP-CLIENT` | +| 2 | Telegram 渠道自建客户端(不引入 bot 框架) | `ARCH-DEPS-STAGE4`, `MOD-TELEGRAM-NOTIFY` | +| 3 | AI 可选接入 | `REQ-AI-EXTERNAL-CONSENT`, `REQ-AI-FUSE` | +| 4 | Redis 落地 | `ARCH-DEPS-STAGE4` | +| 5 | 生产化(debug 面、网关、对账) | `MOD-DEBUG`, `SAF-DEBUG-GATE` | + +**阶段编号是历史顺序,不是当前状态声明。** 当前状态以 `reference.md` 与代码为准。 + +## 5. Prohibited matters + +1. **不得引入未在本文件 §2 登记的依赖。** 新增依赖须先在本文件登记并说明理由。 +2. **不得为通过门禁而删除测试、跳过校验、放宽检查级别、删除访问控制。** +3. **不得在文档或注释中引用不存在的依赖、函数、类型、Redis 键或行号。** +4. **不得在代码中写「计划实现 X」类注释**;占位实现必须显式标注为占位。 +5. **不得在 SPA / 网关引入构建工具或框架**;`web/` 保持单文件 JS + 单文件 CSS。 +6. **不得让 SPA 持久化任何凭据**(无 `localStorage` / `sessionStorage` / Cookie 赋值)。 +7. **不得提交未验证的代码**;门禁结果必须来自本轮实际执行。 +8. **不得声称已通过真实环境验证,除非本轮真的连过。** +9. **不得在 `web/`、`cloudflare-worker/` 之外引入前端资源**。 +10. **不得让业务配置静默回退到环境变量。** + +## 6. Test acceptance and access control + +### 6.1 Change verification closed loop + +任何涉及运行时行为或可核对事实的改动,**同一改动内**完成:实现 → 测试 → 文档。 + +不得只改代码不改文档,也不得只改文档不改代码。 + +### 6.2 GATE-P0 (code access control) + +Debian 最小发行版容器内执行,容器内必须显式 `export PATH=/usr/local/cargo/bin:$PATH`: + +```bash +cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ + -e HOME=/tmp -e RUSTUP_HOME=/app/.gate-cache/rustup -e CARGO_HOME=/app/.gate-cache \ + -v "$PWD":/app -w /app rust:1-slim-bookworm \ + bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' +``` + +**当前基线:93 passed / 0 failed / 4 ignored**(新增 4 个游标解析与 walk 分页单测后复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app` 需外部真实 JMAP 服务器凭据,`real_redis_ttl_tests::ttl_claim_dedup_sets_the_exactly_requested_expiry` 与 `real_redis_ttl_tests::ttl_consent_and_retry_landing_on_real_redis` 需 `REDIS_TEST_URL`)。 + +**注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 + +### 6.3 GATE-DOCS (Document Access Control) + +```bash +cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh +``` + +6 个校验器全过、exit 0 才算通过。只读,从不写文件。 + +新增 `check_file_size`:`src/`、`web/`、`cloudflare-worker/src/` 下超过 500 行的源文件必须带 +`SPLIT-EVAL:` 标记(AGENTS.md §2.1)。它证明标记存在,不证明写下的拆分理由站得住脚。 + +新增 `audit_paths`:`docs/*.md` 与 `AGENTS.md` 里反引号内的路径型 token 必须真实存在。 +它存在的原因是:文档曾出现从未创建的路径,而虚构路径读起来和真实路径完全一样,读者无法分辨 +「我们决定不做」和「这写错了」。`docs/retired.md` 同样在检查范围内——废弃登记里的路径也必须真实, +未落地的形态只以文字描述。豁免只有三类,都在脚本里可见:指向外部仓库的所有者前缀 +(`stalwartlabs/...`)、git 历史可证已删除的文件、以及一份注明理由的知名文件名白名单。 +它不检查反引号外的散文,裸文件名也只按同名校验解析。 + +**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。 +**它不证明语义正确。** 报告校验结果时必须说明这个区分。 + +### 6.4 Front-end testing + +```bash +cd /home/okabe/Repo/messageweave/web && node --test *.test.mjs +cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js +``` + +### 6.5 Invariants must have assertions + +§3 的每条安全不变量都要有一条测试或代码级断言能证明它成立, +而不是只写在文档里。纯重构不得改变测试总数。 + +### 6.6 External dependency testing + +Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查询与**入站回调**两个方向 +均已验证(`setWebhook` 与 `PushSubscription` 的注册、回调、验证往返全部走通),托管 Redis +已连接。真实环境集成测试必须用 `#[ignore]` 标注,缺凭据时静默跳过(不泄露凭据、不判失败)。 + +**环境阻塞项:无。** 最后两项也已验证:TTL 数值在真实 Upstash 实例上实测(`SET … NX EX` / `SET … EX 3600` / Lua `EXPIRE 86400` 三处原子写入点,连跑 6 轮全绿),`Email/changes` 的 `newState` 续传在真实 Stalwart 环境触发过(见 docs/design.md §10.5)。[`opengaps.md`](opengaps.zh-CN.md)「阻塞」区当前为空。 + +## 7. Document boundaries + +每个文档只回答一个问题;**不得跨文档复制表格、清单或数值。** + +| 文档 | 回答什么 | 权威范围 | +|---|---|---| +| [`../AGENTS.md`](../AGENTS.md) | 通用、语言无关的代码编写与环境构建规范 | 质量规则 | +| `README.md` / `README.zh-CN.md` | 面向使用者:是什么、怎么跑、怎么配置 | 用户可见事实(中英必须信息对等) | +| `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 | +| `docs/deployment.md` | 怎么部署:Dockerfile、secrets、网关、多实例、cron | 部署与运维 | +| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | +| `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 | +| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | +| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 | +| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) | +| `cloudflare-worker/README.md` | 网关自身的配置与语义 | 网关 | + +**跨文档引用规则**: + +- 跨文档**禁止**用 `§x.y` 章节号(章节号随编辑漂移);用稳定 ID。 +- **同文档内**允许 `§x.y`。 +- 新增稳定 ID 必须先登记到 §8 注册表;未登记 ID 不得出现在注释、文档或标识符中。 +- **改公共 API / Redis 键与 TTL / 配置项 / 错误码 / 用户可见文案时,同轮同步全部相关文档。** +- **新增文档必须加入 `scripts/docs_check/` 各校验器的文档清单**,否则它永远不会被校验。 + +## 8. Stable ID registry + +**唯一权威索引表。** 跨文档引用一律用稳定 ID(不用 `§x.y` 章节号)。 + +- ID 语义稳定、全仓唯一、可 grep;本表登记每个 ID 的**定义文件**与一句话说明。 +- 新增约束 / 组件时,先在本表登记一行,再在定义点写 `**ID**` 标签。 +- 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。 +- 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。 + +共 85 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)· +`FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)· +`BOUND-*`(边界)· `ARCH-*`(架构)。 + +| ID | 定义文件 | 一句话 | 类别 | +|---|---|---|---| +| `C-DOCKER` | docs/deployment.md §0 | 必须 Docker 部署 | 部署约束 | +| `C-DEBIAN-SLIM` | docs/deployment.md §0 | Debian slim,禁 Alpine | 部署约束 | +| `C-NO-SECRET-IN-IMAGE` | docs/deployment.md §0 | secrets 不进镜像 | 部署约束 | +| `C-RUSTLS` | docs/deployment.md §0 | rustls + native-roots | 部署约束 | +| `C-HTTPS-INBOUND` | docs/deployment.md §0 | HTTPS-only 入站,容器内明文 HTTP | 部署约束 | +| `C-HTTPS-URL` | docs/deployment.md §0 | 公网 HTTPS URL 由平台提供(bot 不持证书) | 部署约束 | +| `C-AUTH-APP-BASIC` | docs/deployment.md §9 / docs/design.md §3.1 | Stalwart 认证 = App Password + Basic | 部署约束 | +| `C-NO-TCP-EXPOSE` | docs/deployment.md §0 | 单监听 `PORT`,不暴露附加 TCP 端口 | 部署约束 | +| `C-NO-LONG-CONN` | docs/deployment.md §0 | 无 SSE/WS/长轮询等长连接 | 部署约束 | +| `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 | +| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 | +| `C-PORT` | docs/deployment.md §0 | 通用 PORT 约定 | 部署约束 | +| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` 常驻,非目标(该变量已随 `Config::from_env()` 删除,代码中已无此标识符) | 非目标 | +| `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE 长连接,非目标 | 非目标 | +| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 | +| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 | +| `NG-LOCAL-VOLUME` | docs/deployment.md §1 | 本地卷持久化,非目标 | 非目标 | +| `NG-SERVERLESS-BIND` | docs/deployment.md §1 | 绑定具体 serverless 平台,非目标 | 非目标 | +| `MOD-DEDUP` | docs/deployment.md §6 | Redis `SET NX` 幂等键 | 组件 | +| `MOD-STREAMS` | docs/deployment.md §6 | Redis Streams 队列 + worker | 组件 | +| `MOD-SINCESTATE` | docs/deployment.md §6 | sinceState 游标(存 Redis) | 组件 | +| `FLOW-NEW-MAIL` | docs/design.md §5.4 / docs/deployment.md §6 | Push 新邮件流 | 数据流 | +| `FLOW-RECONCILE` | docs/deployment.md §6 | 外部 Cron 对账补差 + Redis 丢失恢复 | 数据流 | +| `REQ-AI-CONFIRM` | docs/design.md §12 | AI 仅在明确要求+确认后接触正文 | 需求 | +| `REQ-VIEW-DIRECT` | docs/design.md §12 | 查看原文始终 JMAP 直取 | 需求 | +| `REQ-LONG-EMAIL` | docs/design.md §12 | 长邮件禁止发全文,AI 摘要 ~300 字 | 需求 | +| `REQ-ANALYSIS-EPHEMERAL` | docs/design.md §12 | 分析/摘要结果不持久化 | 需求 | +| `REQ-ATTACH-ONDEMAND` | docs/design.md §12 | 附件按需拉取 | 需求 | +| `REQ-AI-FUSE` | docs/design.md §12 | AI 失败约 3 次 → 熔断 + 用户确认回退 | 需求 | +| `REQ-LLM-OPENAI-COMPAT` | docs/design.md §12 | OpenAI-compatible 环境变量 | 需求 | +| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 | +| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | +| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | +| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | +| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | +| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 | +| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | +| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | +| `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 | +| `SAF-CHAT-ALLOWLIST` | §3 / docs/design.md §7.3 | CHAT_ALLOWLIST 硬约束,处理前先拒绝非白名单 | 安全 | +| `SAF-AUTH-RECONCILE` | §3 / docs/design.md §7.3 | `/reconcile` 需 `Authorization: Bearer RECONCILE_TOKEN`,fail-closed | 安全 | +| `SAF-AUTH-TG-WEBHOOK` | §3 / docs/design.md §7.3 | `/webhook/tg` 需头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET` | 安全 | +| `SAF-AUTH-JMAP-PUSH` | §3 / docs/design.md §7.3 | `/push/jmap` 按 `pushSubscriptionId` 查 Redis 短期验证状态(存 `session_digest` 摘要),缺失或摘要不符拒绝;状态缺失回落 worker 重新验证(fail-closed) | 安全 | +| `SAF-ADMIN-SESSION` | src/config.rs `session_digest` / §3 | 返回用于 Redis admin-session 记录的 SHA-256 摘要;bearer token 本体绝不写入 Redis | 安全 | +| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 | +| `SAF-PROBE-PUBLIC` | §3 / docs/design.md §7.3 | `/healthz`、`/ready` 公开探针:无鉴权、无敏感信息 | 安全 | +| `ARCH-HEALTHZ` | docs/design.md §7.3/§10.0 | `/healthz` liveness(进程存活),语义长期稳定 | 架构 | +| `ARCH-READY-BASELINE` | docs/design.md §7.3 / src/notify.rs `ready`+`probe_jmap_session`+`probe_telegram_get_me` | `/ready` 端到端就绪:配置完整性 + Redis 可达性 + **出站只读探测**(JMAP session `GET`、Telegram `getMe`,各 `PROBE_TIMEOUT`=3000ms、并行,最坏约 3s),四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`);探针只读、无状态写入(`refresh_business_config` 仅读 Redis),bot token 仅用于拼 URL | 架构 | +| `GATE-P0` | docs/design.md §10.0 | 阶段0 P0 门禁(fmt/clippy/test 过 Debian 容器等 8 项) | 流程 | +| `BOUND-STAGE1` | docs/design.md §10.0 | 阶段1 推进边界(过 GATE-P0 才进;R1 入口鉴权已在阶段0 落地) | 流程 | +| `ARCH-CONFIG-ENV` | docs/design.md §7.1 | 配置=环境变量手工解析,无 figment/TOML | 架构 | +| `ARCH-AXUM-08` | docs/design.md §10.0 | 单端口 axum 0.8 入口(版本以 Cargo.toml 为准) | 架构 | +| `ARCH-STAGE0` | docs/design.md §10.0 / §5.3 | 阶段0 实际交付与现状 | 架构 | +| `ARCH-DEPS-STAGE0` | docs/design.md §10.0 / §4 | 阶段0 实际依赖集(axum/serde/secrecy/subtle/tokio/tracing…) | 架构 | +| `ARCH-DEPS-STAGE1` | docs/design.md §10.0-1 / §4 | 阶段1 依赖现况:`jmap-client 0.4.2` 已引入,版本/features 以 Cargo.toml 为准,禁用 WebSocket feature | 架构 | +| `ARCH-DEPS-STAGE4` | docs/design.md §10.0-1 / §4 | 阶段3.5/4 后依赖现况:`redis 0.27`、`reqwest 0.13` 已引入并实际使用;`teloxide` **未**引入(Telegram 由 `src/channel.rs` 用 reqwest 自研实现);版本一律以 `Cargo.toml` 为准 | 架构 | +| `MOD-JMAP-CLIENT` | docs/design.md §6-7 / §4 | `domain::jmap::client` 真实只读 adapter(session/account/mailbox/email 只读) | 组件 | +| `MOD-TELEGRAM-NOTIFY` | src/worker.rs / docs/design.md §12 | 有界元数据通知 worker:消费出站队列,把脱敏通知经 Telegram 投递(不承载正文/AI 响应) | 组件 | +| `REQ-JMAP-SESSION-URL` | docs/design.md §7.1 / .env.example | `JMAP_SESSION_URL` 接受服务基地址或完整 /.well-known/jmap,归一化为 origin/base 后再交 jmap-client(无重复路径);**代码已实现(D-G1-1),已在真实账号真机验证**(2026-09-28:`fetchChanges` 持久化 `baseline:` 游标、`Email/query` 搜索回帖,均经用户确认) | 需求 | +| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 | +| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 | +| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 | +| `ARCH-LB-WORKER` | docs/deployment.md §10 | 多实例 LB/HA:免费 Cloudflare Worker 作唯一对外入口 + 故障转移,后端为多平台同镜像 | 架构 | +| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron 只登记 Worker 的稳定 URL;后端平台入口不对外登记 | 约束 | +| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | 多实例必须共享同一组 `SAF-AUTH-*` secret,否则随机 401 | 约束 | +| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | 信任模型=透传:Worker 不改写鉴权;后端必须继续 fail-closed 校验(后端可能被公网直连) | 安全 | +| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | +| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | +| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | +| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | +| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | +| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | +| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | +| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 18 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | +| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | +| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | +| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | +| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | +| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 | +| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 | +| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 | +| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 | +| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 | +| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 | +| `GATE-UPTIME-KUMA` | docs/deployment.md §10.1 | `/healthz` 稳定语义可直接接 Uptime Kuma 等外部探针 | 门禁 | +| `GATE-DOCS` | scripts/docs_check/run_all.sh / docs/deployment.md | 文档门禁:校验器全部 0 error、exit 0 才算通过 | 门禁 | diff --git a/docs/deployment.md b/docs/deployment.md index afd09a9..da9abb8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,953 +1,222 @@ -# MessageWeave 部署方案(通用 HTTPS-only Docker) +# MessageWeave deployment guide -> 部署目标:**通用 Docker 容器平台**(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台), -> **不绑定** Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(`NG-SERVERLESS-BIND`)。 -> -> 保持 Docker health / PORT 等通用 HTTP 约定(`C-PORT`)。 -> 部署形态 = **短请求模型**:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。 -> 硬约束见 [§0](#0-硬约束);非目标见 [§1](#1-非目标non-goals); -> 架构 / 产品行为以 [design.md](design.md) 为准,本文只写部署/运维。 +> [中文版本 / Chinese version → deployment.zh-CN.md](deployment.zh-CN.md) ---- +Use this guide to deploy a backend, configure mail delivery, and verify notifications. +Choose any platform that can run the backend container, provide encrypted environment +secrets, and connect to an externally managed Redis service. The optional Cloudflare Worker +gateway adds a stable URL in front of one or more backends. Platform-specific settings are +in the [deployment platform reference](reference.md#9-deployment-platform-details). -## 0. 硬约束 +## 0. Before deployment -> 下述约束均为**不可违反**项(对应 `docs/charter.md §3` 安全边界)。跨文档引用一律用稳定 ID,索引见 `docs/charter.md §8`「稳定 ID 注册表」。 +Prepare an externally managed Redis service with persistence enabled and a public HTTPS URL +that reaches the backend. The platform or proxy terminates TLS; MessageWeave listens on one +HTTP port (`PORT`, default `8080`). The process needs two encrypted boot secrets: +`REDIS_URL` and `CONFIG_ENCRYPTION_KEY` (a random 32-byte key encoded as 64 hexadecimal +characters). Never put real values in source control, an image, or logs. Business settings +are saved in Redis through the protected configuration API; adding them as environment +variables does not configure the service. -| ID | 约束 | -|---|---| -| **C-DOCKER** | 必须是 Docker 容器;禁止非 Docker 直装 | -| **C-DEBIAN-SLIM** | Debian 系 slim,禁止 Alpine/musl | -| **C-NO-SECRET-IN-IMAGE** | secrets 不进镜像(构建 ARG/ENV 不得含 token/密码) | -| **C-RUSTLS** | rustls + rustls-native-roots,不依赖系统 OpenSSL | -| **C-HTTPS-INBOUND** | 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS | -| **C-HTTPS-URL** | **公网 HTTPS 入口由平台提供**:平台/反代给 bot 一个公网 HTTPS URL(如 `https://bot.example.com`),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443 | -| **C-NO-TCP-EXPOSE** | 不依赖 TCP 端口暴露:全容器**单监听端口 `PORT`(默认 8080)**;对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口) | -| **C-NO-LONG-CONN** | 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 **Push HTTPS 回调 + 外部 HTTPS Cron 对账** 保证 | -| **C-REDIS-ONLY-STATE** | 无状态形态只依赖**外部 Redis**:会话、去重、Redis Streams、熔断计数、**sinceState 游标** 全部走 Redis;**不用 SQLite、不用本地卷**(`NG-SQLITE-PERSIST` / `NG-LOCAL-VOLUME`) | -| **C-REDIS-MANAGED-AOF** | Redis 由**用户托管**(自建或托管服务)并**开启 AOF 持久化**:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢 | -| **C-NO-DB** | **生产不使用任何数据库**:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(`C-REDIS-ONLY-STATE`)。应用不得自带、不自建、不连接第二个数据库实例 | -| **C-NO-LOCAL-WRITE** | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷(`NG-LOCAL-VOLUME`/`NG-SQLITE-PERSIST`) | -| **C-LOG-STDOUT-ONLY** | **日志只写 stdout/stderr**(容器平台/运行时负责采集落盘);禁用 `rolling-file`、`FileAppender` 等文件日志后端;日志中**不得**出现密钥、邮件正文、AI 请求/响应内容(`SAF-LOG-PURITY`) | -| **SAF-LOG-PURITY** | 日志/Redis 写入内容**仅限**:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 `password`/`token`/`secret`/`Authorization` 原文、JMAP 正文、LLM prompt/completion、附件内容 | -| **C-NO-STATEFUL-RECOVERY** | **禁止依赖进程内状态做生产恢复**:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(`FLOW-RECONCILE`/`C-REDIS-ONLY-STATE`)实现;进程内缓存仅为性能优化,丢失必须安全可重入 | +Production has no local database, data volume, or internal scheduler. Full security boundaries +and prohibited modes are in [`charter.md`](charter.md). ---- +## 1. Deploy a backend -## 1. 非目标(Non-Goals) +### Container platform -> 以下均为**历史早期参考**,**非目标、不再支持**。不得在实现中复活为运行模式。 - -| ID | 非目标 | 说明 | -|---|---|---| -| **NG-SERVER-MODE** | 常驻长连接运行模式(历史名 `RUN_MODE=server`) | 早期 EventSource+长轮询 常驻设计,已删除;`RUN_MODE` 变量本身也已随 `Config::from_env()` 一并删除,代码中已无该标识符 | -| **NG-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 | -| **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook | -| **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) | -| **NG-LOCAL-VOLUME** | `/app/data` 等本地卷持久化 | 无本地持久层(`C-REDIS-ONLY-STATE`) | -| **NG-SERVERLESS-BIND** | 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 | 部署目标是通用 Docker 容器平台;不绑定平台 | - ---- - -## 2. Debian Slim 选型 - -(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定) - -### 2.1 可选远程联调面 `/debug/*`(`SAF-DEBUG-GATE`) - -生产入口默认**绝对关闭**:`/debug/*` 是一套只读探测 + 单条出站通知的远程联调面,只有部署者主动开启才会出现。 - -**双因子启用条件(`SAF-DEBUG-GATE`,两者必须同时成立)** - -1. 「开启信号」成立——满足**其一**即可:进程命令行带 `--debug`(本地 / 开发),或 - `DEBUG_ENABLED` 环境变量为真值(`1` / `true` / `TRUE` / `True` / `yes` / `YES`,大小写 - 严格匹配,其余取值一律视为关闭)。判定逻辑在 `src/main.rs:15-25` 的纯函数 - `debug_surface_requested`,读取点在 `src/main.rs:102-105`。 -2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:106-114`)。 - -`DEBUG_ENABLED` 而不是 `DEBUG_TOKEN` 单独生效,是刻意的:**只看 token 是否配置会退化掉双因子** -——token 是长期存在平台上的 Secret,让它自己开门就等于没有第二道门。`--debug` 与 -`DEBUG_ENABLED` 二选一即可,因为开关信号本身就是一道门,第二道门始终是 token。 - -**为什么生产用 `DEBUG_ENABLED` 而不是改启动命令加 `--debug`**:这样启动命令保持静态, -开关就成了控制台上一个环境变量的事,改一次 + 重新部署即可,不用提交、不用改 -`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 -`src/main.rs:217-264`。 - -缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1769`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 - -**鉴权** - -7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:47-52`),它委托生产同款 `worker_authorized`(`src/notify.rs:514`),因此令牌是**常数时间比较**(`src/notify.rs:1557`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:54-56`)。 - -**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) - -| 路由 | 成功 | 失败 | -|---|---|---| -| `GET /debug/ping` | `200 {"ok":true}` | 401 | -| `GET /debug/config` | `200` 见下段 | 401 | -| `GET /debug/redis` | `200 {"reachable":true,"global_enabled":…}` | 401;Redis 探活失败**仍为 200** `{"reachable":false,"detail":""}`,不返回 503 | -| `GET /debug/jmap` | `200 {"ok":true}` | 401;探针失败**仍为 200** `{"ok":false,"detail":…}`(与 `/ready` 共用同一探针) | -| `GET /debug/telegram` | `200 {"ok":true}` | 401;同上,探针失败返回 200 + `ok:false` | -| `GET /debug/worker` | `200`(`revision`、`reconcile_cursor`、`outbound` 预算) | 401 | -| `POST /debug/notify` | `200 {"ok":true,"result":{…}}` | 401;业务配置未加载或无出站客户端 → `503 service_unavailable` + `Retry-After: 30`(`src/debug.rs:58-60`);`chat_id` 不在白名单 → `403 chat_not_allowed`(`src/debug.rs:62-64`);Telegram 发送失败 → `502 telegram_send_failed`(`src/debug.rs:66-68`) | - -`/debug/redis` 失败时的 `detail` 取值(`src/debug.rs:176-194`):`authentication_failed` / `io_error` / `busy_loading` / `client_error` / `response_error`(redis 侧,`ErrorKind` 为 `#[non_exhaustive]`,未识别归 `response_error`)或 `state_lock_poisoned` / `encryption_failed`(本进程侧)。这一分类就是区分「密码写错」与「Redis 宕了」的唯一依据。 - -`/debug/config` 的响应字段(`src/debug.rs:96-152`):恒有 `revision`、`setup_missing`、`business_configured`、`allowlist_size`;业务配置存在时再加 `timezone`(IANA 时区名字符串,如 `Asia/Tokyo`)、`jmap.{session_url,username,account_id}`、`telegram.{chat_id,webhook_secret_configured}`、`worker.{worker_token_configured,reconcile_token_configured}`、`llm.{enabled,allow_net,api_key_configured,base_url,model}`、`outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries}`;配置缺失时只回 `business_configured:false` + `allowlist_size`。 - -方法不匹配先于鉴权判定(例如 `GET /debug/notify` 返回 `405`)。 - -**三条约束,部署时务必确认** - -- **绝不回显凭据值**:`debug_config`(`src/debug.rs:96-152`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 -- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1563`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:257-260`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:265-270`)。 -- **不在 Worker 白名单内,只能直连 origin**:网关的 18 条安全路由(`cloudflare-worker/src/backends.js:10-30`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:95-96`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 - -> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 -> `DEBUG_TOKEN`,重新部署后联调;结束立即把 `DEBUG_ENABLED` 置空并轮换 `DEBUG_TOKEN`。 -> 全程不需要改动启动命令或提交代码。长期暴露面走 §5 的 Secret 管理流程单独审批。 - ---- - -## 3. Rust 多阶段构建与运行 - -> **边界:本章与仓库根目录的 `Dockerfile` 是「仅本地开发 / 本地容器调试」路径, -> 生产不执行它。** 线上走 §5.1 的 `hoststack.yaml` + `runtime: rust`,由 HostStack 自带 -> agent 在 `rust:slim-trixie` 构建、拷进 `debian:trixie-slim` runner 运行,完全不经过 -> 本 Dockerfile(连 Debian 版本都不同)。因此本章的 `ENTRYPOINT`(tini)、 -> `EXPOSE 8080`、内置诊断工具在生产容器中一律不生效。生产启动命令的真源是 -> `hoststack.yaml` 的 `start.command`。本地用 `docker build` + `docker run` 验证行为时 -> 才走本章。 - -### 3.1 构建阶段 -(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 `message-weave`) - -> **实现现状**:单端口 axum 入口、`jmap-client =0.4.2` 只读 adapter、Redis Streams、Telegram Webhook、JMAP Push 入队和 `/reconcile` 已接入;三条入口路由鉴权 fail-closed 落地(`R1`/`SAF-AUTH-*`)。本地 Debian 容器门禁已通过;真实 Stalwart、Redis、Telegram 的端到端链路仍需部署后验证。配置引导模式会在启动变量缺失时保持 HTTP/SPA 可访问,不伪造业务成功。 - -### 3.2 运行阶段(收敛为单端口) -- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口) -- `ENV PORT=8080`(可选,缺省即 8080;`REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY` 必须由运行期注入,不写入镜像) -- 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口 -- **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`) - ---- - -## 4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口 - -- 非 root 用户运行(保留既有约定) -- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、`/push/jmap`、`/reconcile` 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。 -- 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;管理员调用 `POST /api/push/register` 并提交该 URL 的 `/push/jmap` 路径,后端负责执行 `PushSubscription/set create`。服务不会自动猜测平台公网域名。 -- **出站 egress 是 `/ready` 的硬依赖**:就绪探针向配置的 JMAP session host 与 `api.telegram.org:443` 发起只读 `GET`。该 egress 不通时 `/ready` 会长期 `503`,即使配置与 Redis 都正常。若 egress 必须经 HTTP(S) 代理则**无法使用**:应用内 `reqwest` 以 `default-features = false` 编译(`Cargo.toml:12`),未启用 `proxy` feature,**不解析 `HTTPS_PROXY`/`HTTP_PROXY`**;这种情况下请用网关聚合的 `/healthz-worker`(无条件 200)作为存活监控,`/ready` 仅作人工排查。 -- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。 -- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接) - -### 4.1 接通两侧回调(操作步骤,已在真机联调验证) - -后端起来、`/ready` 全绿之后,还需要**手动执行两件事**邮件才会进来。两步都由本服务执行 -Telegram / JMAP 侧的写操作,运维只提供 URL 与 token。 - -**第一步:Telegram 入站(`setWebhook`)** - -```bash -curl -fsS -X POST "https://api.telegram.org/bot<你的BOT_TOKEN>/setWebhook" \ - -H "content-type: application/json" \ - -d '{ - "url": "https://<你的平台URL>/webhook/tg", - "secret_token": "<必须与业务配置 TG_WEBHOOK_SECRET 逐字符一致>", - "allowed_updates": ["message"] - }' -``` - -预期回执 `{"ok":true,"result":true,"description":"Webhook was set"}`。 - -- `secret_token` 必须与 `TG_WEBHOOK_SECRET` **逐字符一致**:Telegram 把它放进请求头 - `X-Telegram-Bot-Api-Secret-Token`,后端常数时间比对,不等即 401 且无任何副作用。 -- `allowed_updates` **必须显式包含 `message`**。漏掉时 Telegram 不推送普通消息,队列里 - 什么也没有——看起来像「回调没通」,实际是订阅范围不对。 -- 回调地址走 Worker 域名(`/webhook/tg` 在 safelist 内)还是后端 origin 直连都可以; - 前面有 Worker 时按 §10 只登记 Worker 的稳定 URL。 -- 确认已登记:`curl -fsS https://api.telegram.org/bot/getWebhookInfo`,看 - `result.url`。Telegram 不回显 `secret_token`,所以 secret 写对没有只能靠最后一步反证。 -- **轮换注意**:在 SPA 改了 `TG_WEBHOOK_SECRET` 而**没有**重跑 `setWebhook`,Telegram - 手里就是旧值——之后所有真实回调都会被 401 拒掉,不报错、无告警,只是静默收不到消息。 - 改了就重跑本步。 - -**第二步:Stalwart 出站订阅(`POST /api/push/register`)** - -```bash -curl -fsS -X POST "https://<你的平台URL>/api/push/register" \ - -H "Authorization: Bearer " \ - -H "content-type: application/json" \ - -d '{"callback_url":"https://<你的平台URL>/push/jmap"}' -``` - -注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, -后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1225`)。 - -- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - (`src/notify.rs:1328-1330`)。 -- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 -- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - (`src/notify.rs:1328-1330`),撞锁返回 409 `conflict`。 -- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。 - 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 -- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart - `/admin → Management → Directory → Accounts/Roles` 检查。 -- **撤销**:带 `{"callback_url":"..."}` 打 `POST /api/push/disable`。 - -**第三步:排空(`POST /worker`)** - -前两步只负责把事件送进队列。把邮件从队列取出来并发成 Telegram 消息,靠外部 cron 周期性 -调用 `POST /worker` 完成——见 §6.3.1(仓库内有现成脚本 `scripts/cron-drain.sh`,参数全走 -环境变量)。**缺这一步,邮件会持续积压在 `stalwart:jmap`,一行通知都不会发出来。** - -**验证**:群内发 `/help`,`/worker` 排空后应收到自动回复(入站方向);给自己发一封新邮件, -排空后应收到该邮件通知(出站方向)。 - ---- - -## 5. Secret 管理与环境变量 - -### 5.1 HostStack 原生 Rust 部署 - -HostStack 使用仓库根目录 `hoststack.yaml`:`runtime: rust` 的 agent 在 -`rust:slim-trixie` 里执行 `install.command`(`cargo fetch --locked`)与 -`build.command`(`cargo build --release --locked`),把产物拷进 -`debian:trixie-slim` 的 runner 容器,以 `start.command`(`./target/release/message-weave`) -启动,监听单个 HTTP 端口,并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查。 -`install:` **是**合法的 schema 键——`install` / `build` / `start` 是三条独立命令, -依赖获取不写进 `build.command`。服务命令里**不带** `--debug`,也**不需要**带——远程联调开关 -用 `DEBUG_ENABLED` 环境变量控制(见 §2.1),改控制台即可,启动命令保持静态。 -`REDIS_URL` 与 -`CONFIG_ENCRYPTION_KEY` 必须配置为 HostStack Secret;不得将密钥值写入 YAML、镜像、日志 -或代码仓库。**本仓库的 Dockerfile 不由该路径执行**——runner 镜像是 HostStack 自带的 -`debian:trixie-slim`,不是本文件的 `debian:bookworm-slim`,见 §3 与 §8.1 的说明。 - -**构建指纹 `BUILD_VERSION`(`build.rs`)**:编译期用 `git rev-parse --short HEAD` + `date -u` -拼出 `+` 并写入 `BUILD_VERSION`。`git` 在构建镜像里通常不存在, -此时退回 `nogit+<时间戳>` —— 时间戳部分是真正可用的判据,不会静默退化成固定值。 -镜像里的 `cargo build` 会自动执行该 build script(Cargo 对 workspace 根 build.rs 无条件执行, -无需额外配置)。SPA 页脚与 `GET /api/status` 的 `version` 字段都取自它,用来确认部署是否真正落地。 - -**配置优先级(三层,从 HostStack agent 源码实测确认)**:部署时 agent 先把控制台 -services 表里已存的值填进 payload,再用 `hoststack.yaml` 声明的值覆盖,最后才用运行时 -框架默认值补缺。源码判定条件是 `if (svcConfig.install?.command)` 才覆盖,且没有 -`&& !payload.x` 之类的短路——**YAML 声明了就以 YAML 为准、无条件覆盖控制台值;YAML -省略的字段则沿用控制台已存的那个值**。框架默认值只有在前两者都为空时才填,而 agent 的 -注释明确写着 *"trigger-payload always populates payload.x from the DB"*,所以对已建好的 -服务这条默认值分支实际上是**死代码**。这也意味着删掉本文件**不会**退回安全的默认值—— -它直接采用控制台现存的那个值。 - -**因此目前不能删**。控制台里现存的 Start Command 是 `./target/release/app`, -这是 HostStack 为 Rust runtime 自带的默认值、与本仓库包名无关:`Cargo.toml` 只声明 -一个 `[[bin]]`(名字 `message-weave`,路径 `src/main.rs`),仓库里既没有 `app` 这个 -二进制目标也没有对应源文件。直接删掉 `hoststack.yaml` 后,下一次部署会以 -`./target/release/app` 启动,进程在绑定端口前就以 `not found` 退出。 -线上实测印证:控制台 UI 显示 `./target/release/app`,而实际 PID 1 是 -`./target/release/message-weave`——说明现在生效的是 YAML,不是控制台。 - -> **决策记录**:最初倾向是"从控制台 UI 管理更方便,把 `hoststack.yaml` 拿掉"。 -> 但按上面的优先级,**这个前提不成立**——直接删 YAML 服务起不来。**当前决定:保留 -> `hoststack.yaml`。** 保留的代价是双写(在控制台改命令不会生效,必须改 YAML); -> 保留的理由是部署命令进入 git 历史、可 review 可回滚,而控制台值散在平台侧、改动无痕迹。 -> -> **这个双写不是我们多此一举,命令字段从设计上就不回写控制台**:agent 源码里只有 -> `yaml_port_declared` 与 `yaml_disk_declared` 两个回写事件,**命令没有**——每次部署都是 -> 拿 YAML 临时覆盖 services 行里的旧值。`port` 与 `disk` 之所以要回写,是因为 -> autoscaling 与 sleep-wake 直接读那行;命令只有部署路径自己读,所以不值得持久化。 -> 结论是控制台对命令字段永远是过期缓存,**不存在"以 UI 为准"的稳态**——这也是 HostStack -> 把它叫 Infrastructure-as-Code 的原因(源码原文:*hoststack.yaml is -> Infrastructure-as-Code: when the repo declares a command, it wins over whatever happens -> to be cached on the services row*)。 -> -> **关于这个文件的来历**:它不是 HostStack 生成的,也没有官方模板可抄——agent 镜像里 -> 既没有 `init` 类命令也没有内嵌的模板 YAML,只负责在仓库根读取 `hoststack.yaml` 或 -> `hoststack.yml`。本文件是 `a571033` 手写的,所以"官方推荐怎么写"没有标准答案,只有 -> schema(见上一条的未知键行为)。 -> -> **若日后确要移除**,必须**先在控制台把三条命令改对**(install = `cargo fetch --locked`、 -> build = `cargo build --release --locked`、start = `./target/release/message-weave`), -> 再删本文件并重新部署一次验证;同时把本段连同该前提移到 `docs/retired.md` 并记下 -> 移除 commit。**不要**只删文件就以为控制台值会自动变对。 - -**校验是宽松的,不是拒绝式的**:agent 的部署路径不应用 zod schema 校验, -`install` / `build` / `start` 等都是可选对象,**未知键会被静默丢弃而不是报错**, -拼错一个键名(如 `healthcheck` 全小写)不会让部署失败,只会让对应配置悄悄失效。 -因此 `hoststack validate`(本地类型检查,不发起 API 调用)是唯一有效的防线, -应放进 CI;本机无 Node 环境时至少保留一次人工 review。 - -全局业务开关通过受保护的 `GET|PUT /api/enabled` 管理,持久化 Redis key 为 -`config:enabled`,默认关闭且读取失败 fail-closed。关闭时 `/webhook/tg`、`/push/jmap`、 -`/reconcile`、`/worker` 返回 503,不执行入队、ACK 或业务处理;SPA、`/api/status`、管理 -会话和配置 API 保持可用,打开后立即生效。 - -> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`。下表字段**已不再从环境变量读取**(`Config::from_env()` 及其助手 `required_secret`/`required_nonblank`/`env_bool` 已删除,`config.rs` 零 `std::env` 读取;登记见 `docs/retired.md`),全部经 Redis 业务配置由 `/api/bootstrap` 或管理员 PUT 写入并在成功后热重建客户端。表内 ✅ 指 **Redis 业务配置**的 fail-closed 必填(`validate_nonblank`,空白即拒绝),不代表需要注入生产容器。**不要把下表变量注入容器**:注入也不会被读取。 - -(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽) - -| Redis 业务配置字段 | 必填 | 说明 | -|---|---|---| -| `BOT_TOKEN` | ✅ | Telegram Bot Token | -| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) | -| `JMAP_SESSION_URL` | ✅ | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):填**服务基地址**(`https://mail.example.com`)或**完整** `…/.well-known/jmap` 均可;代码归一化为 origin/base 后再交 `jmap-client`,**不产生重复路径**。仅 HTTPS;**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) | -| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth。通常内置 `user` 角色已包含 PushSubscription 的读取/创建/修改/删除权限,无需手工配置;若实际调用返回 `forbidden`,再到 `/admin → Management → Directory → Accounts/Roles` 检查 | -| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 | -| `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;支持 `redis://` 与带默认 ACL 用户的 `rediss://default:@host:6379/0`,Redis 进程不在此 compose 内 | -| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) | -| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer ` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此字段为**必填**(`SecretString`) | -| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session | -| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) | -| `DEBUG_ENABLED` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):`DEBUG_ENABLED` 为真值(或命令行带 `--debug`,用于本地开发)**且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 | -| Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 | -| `LLM_*` | 可选 | OpenAI-compatible 业务配置字段(`REQ-LLM-OPENAI-COMPAT`);其中 `LLM_MAX_RETRIES` **不是**业务配置字段而是 Redis 运行参数(回落默认见 `docs/reference.md` §6.1);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | -| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | -| `TIMEZONE` | 默认 `Asia/Shanghai` | 通知里的收件时间按此时区渲染(`REQ-TIMEZONE-DISPLAY`,wire 键为小写 `timezone`)。在配置页第 `04` 节「通知显示 / Notification display」的选择器里设置,不是环境变量。**只接受 16 个固定偏移时区**(`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`),不在表内时 `PUT /api/business-config` 返回 **422** 且旧配置继续生效。**不跟踪夏令时**:离线构建没有 IANA tz 数据库(`chrono-tz` 不可用),所以只收录全年无夏令时的区域;需要伦敦/纽约这类区域请自行换算成对应的固定偏移区域 | - -AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 - -> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 -> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 - -> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 `CONFIG_ENCRYPTION_KEY` 后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 通过 `GET /api/business-config` 回读已保存配置:返回 10 个非密钥字段的值 + 6 个密钥的存在性布尔(`SAF-NO-SECRET-ECHO`,不返回密钥值本身);`PUT` 接受部分补丁,只替换提交中出现的字段,省略的密钥沿用已存值,因此表单回读后只需修改要改的字段,无需重新填写密钥。但后端**不**把空字符串当作"未提交":显式提交 `""` 会真的清空该密钥,"留空即保留"这一保证由 SPA 在提交前丢弃空白密钥字段实现;`chat_allowlist` 提交空数组会被拒绝(422)。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 - ---- - -## 6. 运行模式与短请求模型 - -### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`) - -生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机 -高熵 hex,仅应用运行时持有);bootstrap 与 admin 会话的认证凭据即 `CONFIG_ENCRYPTION_KEY` -本身;业务密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap -会话:请求必须以 `CONFIG_ENCRYPTION_KEY` 作为 Bearer 凭据,服务端只做常数时间比较,绝不在 -响应、日志或配置值中回显该密钥。bootstrap 使用原子 `SET NX` 写入完整业务配置, -竞争请求只有一个成功;**ACL 密码缺失不再阻塞初始化**(TLS-only 托管 Redis 场景可用), -仅 `CONFIG_ENCRYPTION_KEY` 缺失或鉴权失败时拒绝。 - -该 ACL 身份是应用自身的 Redis 连接凭据,仍必须具备 `config:business`、 -`config:outbound` 及 `config:admin_session` 键的读写权限(bootstrap 与管理员 PUT 均经此 -连接写入),不得误配置为禁止配置键访问;但它不再是任何 HTTP 认证的信任根——业务配置 -明文受 `CONFIG_ENCRYPTION_KEY` 做 AES-256-GCM 加密保护,仅持有 ACL 密码读取到的也是 -密文。bootstrap 完成后公网业务配置接口只接受 900 秒 admin session。 - -成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 -或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 -不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 -任何密钥值,只返回密钥是否存在(presence 布尔),配置缺失或业务依赖未就绪时保持 -HTTP/SPA 可用但业务路由返回未就绪。 - -`config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) -写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应 -或浏览器存储。该保护只防 Redis 内容泄露,不替代 `rediss`、VPN 或受信网络对 `redis://` -窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。 - -bootstrap 或管理员 PUT 修改 `config:business` 后,服务会先校验并构建全部 JMAP/Telegram/LLM -客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在 -请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。 - -TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网, -并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 -内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。 - -> **单一运行形态**:进程只有单条服务命令 `./target/release/message-weave`,webhook handler、JMAP Push 回调、Redis Streams worker 与 `/reconcile` 同处一个进程;对账是一次性 HTTP 端点 `POST /reconcile`(由外部调度器/容器任务调用),**不是**独立子命令,也**没有** `RUN_MODE` 变量(该标识符已随 `Config::from_env()` 删除,见 `docs/retired.md`)。无长连接(`C-NO-LONG-CONN`)。 - -### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站) -``` -# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080 -GET / 业务配置与运行参数管理 SPA -GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式 -GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名 + version 构建指纹 -POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session -POST /api/admin/session/revoke Bearer admin session;成功返回 204 -GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 -GET|PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;GET 回读非密钥值 + 6 个密钥存在性布尔(不回显密钥值), - PUT 接受部分补丁(省略的密钥沿用已存值),校验通过后必持久化, - 客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true -POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建, - 不写入、不换 worker;返回 200 + 逐组件结论 -POST /api/bootstrap 与 PUT 同契约(失败也持久化并回 200+warnings) -POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token] - → 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径) -POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body pushSubscriptionId + verificationCode] - → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步) -POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN] - → 对账补差 FLOW-RECONCILE -GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息) -GET /ready 公开就绪探针;检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP `/.well-known/jmap` 带 Basic 认证、Telegram getMe,各 3s),四者全过返回 200+就绪报告 JSON,任一失败返回 503+标准错误 envelope(无鉴权、无敏感信息) -``` -`/reconcile` 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。 -> **写入口鉴权(fail-closed,`SAF-AUTH-*`)**:Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。 -> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**。`/healthz` 只表示进程存活;`/ready` 检查配置完整性与 Redis 可访问性,并发起只读出站探测(JMAP `/.well-known/jmap` 带 Basic 认证 与 Telegram getMe,各 3s、并行);不触发邮件同步等业务副作用。 -> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。 - -### 6.2 Redis Streams / worker(MOD-STREAMS) -- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 -- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:390`,路由 `src/notify.rs:1761`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:301`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 -- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:407`;批量读取在 `src/state.rs:499`),逐条处理后确认消费(`src/state.rs:430`);处理失败转重试 / DLQ(`src/notify.rs:497`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 -- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) - -### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) -- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:420`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。 -- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 -- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 -- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 -- **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。 - -#### 6.3.1 用户侧调度示例(copy-paste 可用) - -端点有两个:`POST /reconcile`(路由 `src/notify.rs:1766`)与 `POST /worker`(路由 -`src/notify.rs:1761`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 -再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 -`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:420`。 -**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 - -最小可运行版本(推荐起点): - -| 项 | `/reconcile` | `/worker` | -| --- | --- | --- | -| 作用 | 增量入队 + 推进游标 | 排空两条 stream 并发通知 | -| 调度顺序 | 先 | 紧随其后 | -| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` | -| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 可以(在 `SAFE_ROUTES` 内) | -| LB 超时 | `LB_RECONCILE_TIMEOUT_MS` 默认 320s,`maxAttempts=1` 不故障转移 | `LB_WORKER_TIMEOUT_MS` 默认 300s,`maxAttempts=1` 不故障转移 | -| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 | -| 单次上限 | 100 条变更(`src/notify.rs:294`) | 10 条消息(`src/notify.rs:407`) | -| 常态返回 | 204 | 204(**不能当成功信号**) | - -两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret -存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有 -admin-session 兜底**(`src/notify.rs:395`),这是有意的:cron 不需要先登录拿 session。 -token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:390`), -再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。 - -两个端点都在 Worker 转发白名单里,**cron 打 Worker 域名和打后端 origin 都能用**。它们走 -per-route 覆盖:各自用独立的更长超时(见上表)并且 `maxAttempts=1` 绝不故障转移——全局 10s -超时会把一次正常的排空误判成失败,而第二个实例对 `/reconcile` 必然撞锁 409、对 `/worker` -只会重复排同一批消息。`/worker` 此前被挡在白名单外(走 Worker 是 404 `route not forwarded`), -现已加入;它和 `/reconcile` 一样只接受 Bearer token,不带人工流量。见 §10.4。 - -不想自己拼 curl 的话,仓库里有现成脚本 `scripts/cron-drain.sh`:两步都做了,参数全部走 -环境变量(`MW_APP_URL` / `MW_WORKER_URL` / `MW_RECONCILE_TOKEN` / `MW_WORKER_TOKEN` -等),凭据不写任何文件。两个额外开关专门用来回答「为什么没收到 Telegram」: - -- `--diagnose`:依次查 `/api/status`、`/debug/config`(配置是否保存、chat_id 是否为占位值、 - webhook secret 是否配置)、`/debug/redis`、`/debug/telegram`(bot token 是否有效)、 - `/debug/jmap`、`/debug/worker`(游标是否还停在 null)。配置问题会直接点出来,不用猜。 -- `--test-notify`:绕过队列直接走生产出站路径发一条测试消息。这条能收到,说明 - token/chat_id/allowlist 全对,问题一定在调度;收不到,问题就在这一层,跟 cron 无关。 - -诊断端点需要后端以 `--debug` 启动并设置 `MW_DEBUG_TOKEN`,且必须直连 origin。 - -最小示例(手动烟测,打印状态码;`/reconcile` **不接受请求体**,游标存在服务端 Redis 里): - -```bash -curl -sS -o /dev/null -w '%{http_code}\n' \ - -X POST "https://<你的平台URL>/reconcile" \ - -H "Authorization: Bearer " \ - --max-time 60 -``` - -`/worker` 也先手动跑一遍,可以直接打 Worker 域名(它与 `/reconcile` 一样在转发白名单里,见 §10.4)。空请求体是合法的,等价于取默认 10 条,不用带 `-d`;它可能跑几分钟,`-m` 要给够(LB 侧默认 `LB_WORKER_TIMEOUT_MS` = 300s): - -```bash -curl -sS -o /dev/null -w '%{http_code}\n' \ - -X POST "https://<你的平台URL>/worker" \ - -H "Authorization: Bearer " \ - --max-time 300 -``` - -最小调度(两步一条 cron,顺序不能反): - -``` -# 每 5 分钟:先对账入队,再排空队列(两步都走 Worker 域名,顺序不能反) -*/5 * * * * curl -fsS -m 320 -X POST https://<你的平台URL>/reconcile \ - -H "Authorization: Bearer $RECONCILE_TOKEN" >/dev/null ; \ - curl -fsS -m 300 -X POST https://<你的平台URL>/worker \ - -H "Authorization: Bearer $WORKER_TOKEN" >/dev/null -``` - -- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账 - 入队的消息同样需要被消费。 -- 两条都用 Worker 域名即可(都在转发白名单里);`-m` 要比 LB 侧的 per-route 超时略大, - 否则 curl 会先掐掉一个本来能完成的排空。 -- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:407`)。 - 传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:411`)。 - -Kubernetes CronJob 示例: - -```yaml -apiVersion: batch/v1 -kind: CronJob -metadata: - name: messageweave-drain -spec: - schedule: "*/5 * * * *" - concurrencyPolicy: Forbid - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - startingDeadlineSeconds: 120 - jobTemplate: - spec: - template: - spec: - restartPolicy: Never - containers: - - name: drain - image: curlimages/curl:8 - command: ["/bin/sh", "-c"] - args: - - | - curl -fsS -m 320 -X POST "${APP_URL}/reconcile" \ - -H "Authorization: Bearer ${RECONCILE_TOKEN}" >/dev/null - curl -fsS -m 300 -X POST "${APP_URL}/worker" \ - -H "Authorization: Bearer ${WORKER_TOKEN}" >/dev/null - env: - - name: APP_URL - value: "https://<你的平台URL>" - - name: RECONCILE_TOKEN - valueFrom: - secretKeyRef: {name: messageweave, key: reconcile-token} - - name: WORKER_TOKEN - valueFrom: - secretKeyRef: {name: messageweave, key: worker-token} -``` - -期望状态码: - -| 状态码 | 含义 | 处理 | -| --- | --- | --- | -| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 | -| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:510` 无条件返回 | -| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:412`) | 修 curl;空请求体合法,等价于取默认 10 条 | -| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:285`) | 检查 cron 的 token 值;重启不解决 | -| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:402`) | 同上;`/worker` 无 admin-session 兜底 | -| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:291` 取锁,`src/notify.rs:355` 返回) | 直接跳过本次 tick,不要重试 | -| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:383`) | - -重试建议: - -- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为: - - 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile` 单飞锁 - (`ttl::RECONCILE_LOCK_SECONDS` = 300s,`src/notify.rs:291`,长对账中每 - `ttl::RECONCILE_HEARTBEAT_SECONDS` = 90s 续租 `src/notify.rs:305`,成功后释放 - `src/notify.rs:350`);`/worker` 靠逐条去重声明防重投,重复触发无害。 - - 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后 - 自然释放,下一次调度会重试。 -- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过 - 调度间隔。 -- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避 - 2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力 - 就不要额外实现——下一个 tick 自然会重试。 -- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:407`); - 积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长 - curl 超时——那只会抬高挂死概率,不会让更多消息被消费。 -- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 -- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**。Push 是主路径,对账是兜底; - 但**排空不是兜底项**——没有它,邮件只会一直进队列,一行通知都不会发出来。 -- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。 -- 走 Worker 转发**无需特殊设置**:网关已为两个排空端点单独覆盖超时与尝试次数——`POST /reconcile` 取 - `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 - `src/notify.rs:305`),`POST /worker` 取 `LB_WORKER_TIMEOUT_MS`(默认 `300000` ms = 后端单条事件 - 下界,batch 拉大时按需上调),两者都 `maxAttempts=1` **绝不故障转移**(对 `/reconcile` 切实例会立刻 - 返回 `409`,对 `/worker` 切实例只会重复排同一批消息)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS` - (默认 `10000` ms)与 `LB_MAX_ATTEMPTS`(默认 `2`),不受影响。 - -### 6.4 可靠性策略(Reliability) - -> 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。 - -| 机制 | ID | 做法 | -|---|---|---| -| **Streams ACK / 重试** | `MOD-STREAMS` | worker 用消费组 `XREADGROUP`;处理成功才 `XACK`;未 ACK 消息在被认领后重投(at-least-once)。处理失败时**不 ACK**,自然重试;设最大投递次数上限,超限转死信(`XADD` 到 `dlq`)并告警,避免毒丸阻塞 | -| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` | -| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 | -| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟调用 `/reconcile`;从 Redis 恢复 `state:jmap:since`,按 `Email/changes` 补差,入队成功后推进游标,失败返回 `503` | -| **运行监控 / 告警** | Uptime Kuma | 使用 HTTP(s) Monitor 检查 `/healthz`(进程存活,无条件 200)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。`/ready` 最坏约 3s(两个 3s 探针并行),探针超时设 ≥10s。应用不引入 Prometheus、Exporter 或额外指标端口。 | - -### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA) - -- **定义**:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ **99.9%**(约每月 ≤43 分钟不可用)。 -- **允许的延迟**:用户已确认**接受少量通知延迟**——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为**对账间隔(≤10 分钟)**。 -- **非目标边界**:99.9% 是**通知可用性**目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。 -- **降级行为**:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。**不因 Redis 抖动而漏发最终通知**(以对账为准)。 - ---- - -## 7. 健康检查(通用 HTTP 约定) - -- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义) -- `GET /ready` → 端到端就绪探针(`ARCH-READY-BASELINE`):检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot/getMe`,各 3s 超时、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200`(就绪报告 JSON 含真实 `jmap`/`telegram` 字段),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`)。因此**LB / ingress 的探针超时必须 > 3s(建议 ≥10s)**。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。 -- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。 -- `/ready` 已对 JMAP session 与 Telegram getMe 做**真实出站探测**(3s/个),但不覆盖真实消息投递验收;编排与 Uptime Kuma 可用它做端到端就绪探测,注意其最坏约 3s,LB 探测超时需 > 3s(见上)。 -- Docker `HEALTHCHECK` 指向同一监听端口(`wget`/`curl` 探同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)。二进制没有 `health` 子命令可指,见 §8.1 的说明 -- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`) - ---- - -## 8. CI 发布与镜像验收 - -### 8.0 代码门禁(`GATE-P0`,进入阶段1前必须通过) -- `cargo fmt --check` -- `cargo clippy --all-targets -- -D warnings` -- `cargo test` - -以上三条**在 Debian `rust:1-slim-bookworm` 容器内执行**(`C-DEBIAN-SLIM`);详见 design.md §10.0。 - -容器以 `--user 1000:1000` 运行,并把 `RUSTUP_HOME`/`CARGO_HOME` 挂载到仓库内持久缓存目录(`-v "$PWD/.gate-cache/rustup:$RUSTUP_HOME" -v "$PWD/.gate-cache/cargo:$CARGO_HOME"`),否则缓存写入因权限失败、工具链每次重建;`.gate-cache/` 已在 `.gitignore` 中,不得提交。门禁结果必须基于**当前仓库快照**重跑得到,不得复用先前快照的通过结果。 - -(其余 CI:镜像构建 → 镜像扫描 → 非 root → `/ready` 端点到容器验证;运行方式见 8.1) - -### 8.1 运行方式与就绪探测 - -> **本节的两条部署路径不要混淆**:上面的构建/运行命令是本仓库 **Dockerfile 路径**(本地 `docker run` -> 或其他容器宿主机);线上 HostStack 部署走 §5.1 的 `hoststack.yaml` + `runtime: rust` agent, -> 在 `rust:slim-trixie` 里构建、拷进 `debian:trixie-slim` 的 runner 容器运行, -> **完全不执行本 Dockerfile**——两者连 Debian 版本都不同(bookworm vs trixie)。 -> 服务 argv 是 `./target/release/message-weave` 而不是镜像里的 -> `tini -- /usr/local/bin/message-weave`,因此镜像内的诊断工具与 `ENTRYPOINT` 在线上都不生效。 -> 判定当前跑的是哪条路径,看容器内 `/proc/1/cmdline` 与 `/etc/os-release`。 - -> **本仓库没有 `docker-compose.yml`。**此前本节给过一段 compose 片段,其中的 `healthcheck` 写成 -> `message-weave health --addr 127.0.0.1:8080` —— 这个子命令不存在(`src/main.rs` 无任何 CLI -> 参数解析,二进制只起 axum 服务)。照抄那段示例必然得到:healthcheck 永久失败,或容器被判定 -> healthy 却什么都不做。示例已删除,改为下面的事实约定。 -> -> 镜像(`runtime` 阶段)现已内置 `curl`,因此在 **Dockerfile 部署路径**下用 `HEALTHCHECK` 探活在 -> 技术上可行;HostStack 官方文档也把 Dockerfile `HEALTHCHECK` 定为把新副本加入 Traefik 池前的 -> readiness 关卡。但 `runtime: rust` 的 HostStack agent 不走本 Dockerfile,该路径的 readiness 由 -> `hoststack.yaml` 的 `healthCheck.path` 决定,所以仍以平台探针为准——`/ready` 是唯一同时校验 -> redis/jmap/telegram 的信号,`HEALTHCHECK` 只能证明进程活着。 - -**运行** +Build the root Dockerfile and configure the required secrets in the platform. For a local +container smoke run, copy `.env.example` to an untracked `.env`, replace both placeholders, +then run: ```sh -cp .env.example .env # 仅需 REDIS_URL 与 CONFIG_ENCRYPTION_KEY docker build -t messageweave:latest . -docker run --env-file .env -p 8080:8080 messageweave:latest +docker run --rm --env-file .env -p 8080:8080 messageweave:latest ``` -- 单端口 8080;平台 ingress/反代映射 HTTPS → 8080(`C-HTTPS-INBOUND`)。 -- runtime 阶段为 `debian:bookworm-slim`,非 root(`useradd --system`,实测 uid 999,非 1000); - 无本地 volume、无数据库引擎(`C-NO-LOCAL-WRITE` / `C-NO-DB`)。 -- 镜像内置只读诊断工具:`curl`、`procps`(`ps`/`pgrep`/`free`)、`iproute2`(`ss`/`ip`)、`jq`、 - `netcat-openbsd`(`nc`),合计实测 **19.7 MB**(全量文件系统 `du` 对比 - `debian:bookworm-slim`)。容器 rootfs 只读,这些工具**只能在构建期装入**, - 运行期无法 `apt` 安装——这也是它们写进 Dockerfile 而不是留给运维现场装的原因。 - 刻意**不装** `bind9-dnsutils`(`dig`):它连带 `libicudata.so.72` 等多级依赖要多花 - **42.7 MB**,是其余 7 个工具总和的两倍多;DNS 解析用基础镜像自带的 `getent` 即可 - (`getent hosts ` 出 A 记录,`getent ahostsv4` / `getent ahostsv6` 分开取)。 - **但这条只对 Dockerfile 部署路径成立**:`runtime: rust` 的 agent 在 - `rust:slim-trixie` 里构建、把产物拷进 `debian:trixie-slim` 的 runner 容器运行, - **完全不执行本 Dockerfile**,因此这些工具在 HostStack 服务里并不存在(线上实测 - `pgrep: not found`)。该路径下改用内核接口诊断:`/proc/1/cmdline`(实际 argv)、 - `/proc/1/environ`(实际 env)、`/proc/net/tcp`、`/proc//fd`。 - HostStack 生产部署的容器安全档(由 agent 镜像内的部署执行器源码实测确认)为 - `readonlyRootfs: true` + `dropCapabilities: true` + `noNewPrivileges: true` + - `pidsLimit: 256`;`/tmp` 与 `/var/tmp` 是 `rw,nosuid,nodev,size=64m` 的 tmpfs—— - 注意**没有** `noexec`,但重启即清空,所以不要在那里放可执行文件或期望重启后仍在的文件。 - **dev 环境与此不同**:dev 档为 `readonlyRootfs: false`、`noNewPrivileges: false`, - rootfs 可写,同一个二进制在 dev 与生产的可写行为可能不同。 -- Redis 由外部已认证实例提供,不与此服务同容器运行(`C-REDIS-EXTERNAL`)。 +The image uses a Rust builder and a Debian slim runtime, runs as a non-root user, and exposes +only port `8080`. Do not mount a data or log volume. Full image contents are in the +[platform reference](reference.md#91-backend-deployment-paths). -**就绪探测:用 `/ready`,不要用 `/healthz`** +## 2. First-time configuration -| 端点 | 语义 | 用途 | +Open the backend's configuration page at its origin for initial setup. Use +`CONFIG_ENCRYPTION_KEY` to establish the admin session, then complete the one-time bootstrap +form. The bootstrap route is not exposed by the Worker gateway. The one-time bootstrap and +admin-session handlers are `src/notify.rs:834` and `src/notify.rs:1187`; saving business +configuration uses `src/notify.rs:694`. Later edits can use the protected page through the Worker URL. + +Configure at least the Telegram bot token, target chat ID, inbound chat allowlist, Stalwart +JMAP HTTPS session URL and application password, webhook secret, and worker token. Set a +reconcile token if using scheduled reconciliation. Configure LLM settings only when users +have explicitly consented to external processing. Secret values are not returned by the +configuration API; the full field semantics are in the [configuration reference](reference.md#5-environment-variables). + +### 2.1 Optional remote diagnostics + +Leave `DEBUG_ENABLED` and `DEBUG_TOKEN` unset in production unless remote diagnostics are +deliberately needed. Both must be configured to mount the diagnostic routes, and those routes +are reachable only through the backend origin. The diagnostic surface and response details +are recorded in the [reference](reference.md#3-backend-routes). + +## 3. Build and run the Docker image + +The root Dockerfile is the container deployment path. It has separate Rust builder and +Debian slim runtime stages; see [§8.2](#82-docker-image-details) for the runtime contents. + +## 4.1 Register Telegram and Stalwart callbacks + +After saving business configuration, use the **Register Telegram and Stalwart callbacks** +section in the configuration page. Enter the public HTTPS origin that receives callbacks: +the Worker URL when the gateway is enabled, otherwise the backend origin. The page registers +`/webhook/tg` with Telegram and `/push/jmap` with Stalwart. Credentials remain in the backend; +the browser sends only the callback URLs. The protected registration handlers are +`src/notify.rs:1439` for Telegram and `src/notify.rs:1315` for Stalwart. + +Telegram's secret token and `allowed_updates: ["message"]` are taken from the saved business +configuration. Stalwart verification and verification-code writeback happen automatically +after registration. Registration can be repeated safely; changing the callback origin updates +Telegram and creates a Stalwart subscription for the new URL. + +To remove a subscription, call `POST /api/push/disable` with the same `callback_url`. + +## 5. Runtime configuration + +Keep boot-time process configuration separate from Redis-resident business configuration. +Only `REDIS_URL` and `CONFIG_ENCRYPTION_KEY` are required at process startup. The full +business field list is in the [configuration reference](reference.md#5-environment-variables). + +## 6. Short-request runtime and scheduling + +The service handles one request at a time and does not keep JMAP event streams, Telegram +long polling, or a background timer open. Telegram/JMAP push provide the fast path; the +external scheduler invokes the endpoints described below. + +### 6.3 Schedule queue draining and reconciliation + +The backend has no internal scheduler. An external cron, scheduled job, or platform timer +must call [`scripts/cron-drain.sh`](../scripts/cron-drain.sh); otherwise queued notifications +remain undelivered. The script calls `/reconcile` (`src/notify.rs:268`) when +`MW_RECONCILE_TOKEN` is set, then drains the worker queue through `/worker` +(`src/notify.rs:390`). + +Provide `MW_APP_URL` and `MW_WORKER_TOKEN`; set `MW_RECONCILE_TOKEN` when reconciliation is +enabled. Store tokens in the scheduler's secret manager. For a one-shot run: + +With the tokens injected by the scheduler's secret manager, run: + +```sh +MW_APP_URL='https://' bash scripts/cron-drain.sh --once +``` + +The reconciliation cadence is normally 5–10 minutes. Both successful endpoints return `204` +with an empty body; do not treat that as an error. The script's `--diagnose` mode helps +separate service configuration problems from scheduling problems. Its arguments and +operating modes are listed in the [scheduler reference](reference.md#93-callback-registration-and-scheduled-work). + +### 6.4 Reliability behavior + +The Worker retries only timeouts and backend `5xx` responses; it returns `4xx` responses +directly. `/reconcile` and `/worker` have longer per-route timeouts and do not fail over to a +second backend, avoiding duplicate work. Backend queue processing is at least once and relies +on Redis-backed idempotency. See the route and state reference for the complete behavior. + +### 6.5 Delivery objective + +The service permits short notification delays and targets at least 99.9% notification +availability. Redis availability and the external scheduling interval are outside the +application's control; exact delivery guarantees are described in `NFR-NOTIFY-SLA` and +`NFR-RECONCILE-INTERVAL` in the project charter. + +## 7. Health checks + +Use `/healthz` for liveness (`src/notify.rs:118`) and `/ready` for dependency readiness +(`src/notify.rs:193`). The verification sequence is in [§8.1](#81-verify-the-deployment). + +## 8.1 Verify the deployment + +Check the backend first: + +| Request | Expected result | Meaning | |---|---|---| -| `GET /healthz` | 无条件 200 | 存活探测(进程还活着) | -| `GET /ready` | 配置、Redis 或上游探针失败时 503 | 就绪探测、负载均衡摘除、Uptime Kuma | -| `GET /api/status` | 恒 200;返回 `{"ready":bool,"mode":"configured"\|"configuration-setup","missing":[...],"version":"<构建指纹>"}`,只列缺少的环境变量名称;`version` 为 `BUILD_VERSION`(`+`) | 排查"起来了但没干活";`version` 用于确认部署是否落地 | +| `GET /healthz` | `200` | The process is alive. | +| `GET /ready` | `200` | Configuration, Redis, JMAP, and Telegram probes are ready; dependency failures return `503`. | +| `GET /api/status` | `ready: true` | Required boot settings are present; `missing` identifies absent names. | -> **由平台 ingress 探测 `/ready`。**镜像现已内置 `curl`(见 Dockerfile `runtime` 阶段), -> 所以在 Dockerfile 部署路径下容器内写 `HEALTHCHECK` 在技术上可行(HostStack 的 `runtime: rust` -> 路径不走该 Dockerfile,readiness 由 `hoststack.yaml` 的 `healthCheck.path` 决定);但 -> `/healthz` 只能证明进程活着,`/ready` 才同时校验 Redis 与上游,因此仍以平台探针为准。 +When the Worker gateway is enabled, also request `GET /healthz-worker`. Require +`available >= 1` and check that `version` matches the deployed `LB_VERSION`; HTTP 200 alone +can mean `no-backends`. The `cloudflare-worker/` directory's tests and deploy command are +described in its README. -**缺必需环境变量不会崩溃,但也不会干活。**缺 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,进程 -降级为只读配置路由(内存态、空 token、`NoopWorker`),容器继续应答请求但不做任何业务。排查这类 -现象:看启动日志告警,或查 `GET /api/status` 返回的 `missing` 列表。 +Send `/help` to the configured Telegram chat and confirm a reply. Then send a new email and +confirm that the external scheduler drains its notification. A healthy `/healthz` only proves +process liveness; it does not prove that Redis or either upstream is usable. -### 8.2 镜像与部署验证检查项(红线自检) +## 8.2 Docker image details -以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线: +The root Dockerfile's builder uses `rust:1-slim-bookworm`; the runtime is +`debian:bookworm-slim`. The runtime installs CA certificates, `tini`, `curl`, `procps`, +`iproute2`, `jq`, and `netcat-openbsd`, creates the `messageweave` system user, sets +`PORT=8080`, and starts the binary through `tini`. It has no `VOLUME` declaration and does +not start Redis. These details describe the repository Docker image; other platforms may provide their own +builder and runtime image. -| # | 检查项 | 关联 ID | 命令/方法 | -|---|---|---|---| -| 1 | **镜像内无数据库引擎**:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 | `C-NO-DB` | `docker run --rm sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]'` 应为空 | -| 2 | **容器内不出现本地可写挂载**:compose/k8s 不声明 `volumes:` 用于数据/日志目录 | `C-NO-LOCAL-WRITE` / `NG-LOCAL-VOLUME` | 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明 | -| 3 | **日志仅 stdout/stderr**:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 `stdout` | `C-LOG-STDOUT-ONLY` | 检查代码:`grep -rEn 'rolling\|FileAppender\|RollingFileAppender' src/` 应为空;`RUST_LOG` 目标不含 `file:` | -| 4 | **日志/Redis 无敏感数据**:日志字段白名单 + CI 扫描 | `SAF-LOG-PURITY` | 代码评审 + 静态扫描(如 `gitleaks` 扫描 `Redis` 写入调用,验证 `SETEX`/`SET` 参数无密钥/正文/AI 内容) | -| 5 | **重启恢复不依赖进程内状态**:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 | `C-NO-STATEFUL-RECOVERY` | 代码评审:`grep -rn 'static mut\|lazy_static' src/` 不得用于生产恢复路径 | -| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm sh -c 'command -v redis-server && exit 1 \|\| exit 0'` 应为 0 | +## 9. Confirmed deployment choices -> 任一检查失败,禁止发布。 +These deployment decisions are settled; they are not per-installation prerequisites. ---- +### 9.1 Public ingress and state -## 9. 已确认决策与少量待办 +The backend uses an external HTTPS ingress and external Redis. Telegram, JMAP Push, and +scheduled requests use the selected public URL. When the Worker gateway is enabled, register +that stable Worker URL with Telegram and Stalwart; do not expose credentials at the gateway. +The stable IDs in this section point to the project charter's registry. -### 9.1 已确认决策(不再作为待确认问题) +## 10. Optional Cloudflare Worker gateway -| ID | 决策 | -|---|---| -| `REQ-SINGLE-ACCOUNT` | **单账户实现**;多账户暂用**多个 bot 实例**(各自 token/配置),不做多账户单实例 | -| `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) | -| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) | -| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) | -| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443);`Q-DEP-A` 已决策:具体平台 URL / 域名与证书配置方(平台自动证书 or 自管反代二选一)由部署环境在发布时确定,仓库文档化两条路径与 4 条路径的可达性验证方式,不替部署方做选择 | -| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器;`Q-DEP-B` 已决策:调度器不限定实现,任意 shell / cron / K8s CronJob / CI scheduled 均可,用户侧示例见 §6.3.1 | -| `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) | -| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) | -| `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) | -| `SAF-RECONCILE-LOCK` | `/reconcile` **不扇出**:用 **Redis 锁**保证同一时刻仅一个实例执行 | -| `MOD-HEALTH-AGG` | Worker 暴露**聚合健康视图**,供外部监控 | -| `NFR-HA-MULTI-INSTANCE` | 双活或主备**均可**;**Redis 单点故障不在本方案范围**(用户外部解决,短暂不可用可接受) | -| `C-NO-DB` | **生产不使用任何数据库**:Redis 是唯一状态存储;应用不自建/不连接第二个数据库 | -| `C-NO-LOCAL-WRITE` | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷 | -| `C-LOG-STDOUT-ONLY` | **日志只写 stdout/stderr**(平台负责采集落盘);禁用文件日志后端 | -| `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 | -| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 | +The Worker provides one public HTTPS entry point for multiple backend origins. All backends +must share the same Redis and business configuration. The Worker forwards only its fixed +route allowlist; bootstrap and `/debug/*` remain origin-only. Detailed route facts are in +the [gateway route matrix](reference.md#4-gateway-vs-backend-route-matrix). -> **最后一项待确认已清空**:`Q-DEP-A`(平台 URL / 域名与证书配置方)与 `Q-DEP-B`(外部调度器选型) -> 均已决策,分别合并进本表的 `C-HTTPS-URL` 与 `NFR-RECONCILE-INTERVAL` 行。两项都是上线时的运维选择, -> 不影响代码结构、不影响门禁;`docs/opengaps.md` 已不再保留「决策待定」块。 -> -> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。 +### 10.1 Topology ---- +Telegram webhooks, Stalwart push callbacks, and external scheduling can use the stable Worker +URL. The Worker forwards to backend HTTPS origins; it does not connect to Redis or JMAP. -## 10. 多实例高可用与 Worker 前置负载均衡(`ARCH-LB-WORKER`) +### 10.2 Multi-instance prerequisites -> 目标:在**多个 serverless 平台各部署一份相同镜像**(各自不同的 HTTPS 入口),最前面用一个**免费 Cloudflare Worker** 作为**唯一对外入口 + 故障转移**;所有实例共享**同一个外部 Redis**。双活(round-robin)或主备(active-standby)**均可**。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。 +Multiple backends can share traffic because request state is stored in Redis, duplicate +deliveries are deduplicated, and reconciliation repairs missed push events. No sticky session +or application code change is required. -### 10.1 拓扑(`ARCH-LB-WORKER`) +### 10.3 Trust model -``` -Telegram setWebhook ─┐ -Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯一登记 URL(C-LB-SINGLE-REG-URL) -外部 Cron ───────────┘ │ Cloudflare Worker(免费) - ├─▶ https://a./… ─┐ - └─▶ https://b./… ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE) - (同一镜像、同一配置、同一组 secret)─┘ -``` +The Worker passes request headers and bodies through. Backend authentication remains required +because backend origins may be directly reachable. Every instance must use the same shared +business credentials and Redis state. -- **只登记 Worker 的稳定 URL**(`C-LB-SINGLE-REG-URL`):Telegram `setWebhook`、Stalwart `PushSubscription.url`、外部 Cron **都指向 Worker**(`https://lb.example.com/webhook/tg`、`/push/jmap`、`/reconcile`)。后端易变的平台入口**不对外登记**。 -- 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;**不引入长连接**(Worker 纯请求-响应,`C-NO-LONG-CONN`)。 +### 10.4 Routes and failover -### 10.2 为什么可直接支持多实例(既有前提) +The Worker rejects unregistered routes with `404` and wrong methods with `405`. Ordinary +requests try at most `LB_MAX_ATTEMPTS` origins (default two), and only a timeout or `5xx` +triggers failover. `/reconcile` and `/worker` are single-attempt routes with longer timeout +overrides. Use the route matrix and tuning values in the [reference](reference.md#4-gateway-vs-backend-route-matrix). -| 前提 | ID | 作用 | -|---|---|---| -| 状态仅外部 Redis | `C-REDIS-ONLY-STATE` | 任意实例可服务任意请求,**无需粘性会话** | -| 幂等去重 | `MOD-DEDUP` | 重复/重试投递**不会重复通知**,故"多实例 + 重试"安全 | -| 对账补差 | `FLOW-RECONCILE` | 兜住 Push 丢失 / cold-start 窗口 | +### 10.5 Callback URLs -> 因此 LB/HA 是**纯运维拓扑**变化;领域逻辑与渠道层无需改动。 +When using the gateway, set Telegram's webhook and Stalwart's push callback to the Worker +domain. The external scheduler can use the same URL for forwarded paths. Bootstrap and +diagnostic endpoints still require the backend origin. -### 10.3 信任模型 = **透传(A)**(`SAF-LB-PASSTHRU`) +### 10.6 Gateway health -- Worker **原样转发** header / body / `pushSubscriptionId` / `verificationCode`,**不做鉴权改写**。 -- **后端必须继续 fail-closed 校验**三条入口(`SAF-AUTH-*`)——**不可省**。原因:小平台通常**不提供防火墙/ACL**,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景**不可行**)。 -- **多实例必须共享业务鉴权配置**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `RECONCILE_TOKEN` 在所有实例上完全一致;Push verification 状态由 Redis 共享。 +`GET /healthz-worker` reports the Worker version and backend availability. Use both +`available` and `version` to verify a deployment; `status: no-backends` can be returned with +HTTP 200 when no valid origin is configured. -### 10.4 路由与故障转移 +### 10.7 Boundaries -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **18 条**(`SAFE_ROUTES` at `backends.js:10-30` + `ROUTE_METHODS` at `index.js:54-73`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`GET|PUT /api/business-config`、`POST /api/business-config/preflight`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`POST /worker`(两条都是外部 cron 触发的排空端点,Bearer 鉴权,见 §6.3.1)、`GET /ready`、`GET /healthz`(源站自身健康检查;LB 自己的聚合探针在 `/healthz-worker`,不在列);**未知路径 404、method 不符 405**,不透传至后端。**后端另有 2 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 2 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`GET|PUT /api/business-config` 与 `POST /api/business-config/preflight` 同属此类必须透传的路由:SPA 由 Worker 服务,配置回读、提交前校验与保存都由它发起,缺了这两条,回读与预校验经网关部署时恒 404/405,整条配置管理页面只剩后端直连可用;两者同样强制 admin-session Bearer 鉴权,且回读只返回密钥存在性布尔(`SAF-NO-SECRET-ECHO`),不新增凭据暴露面。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 -- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz-worker`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, version, available, total, backends:[{origin,up,status}]}`(`version` 取 `LB_VERSION`,用于确认线上是哪次部署在回答);≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 -- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 与 `POST /worker` 固定 `maxAttempts=1`,绝不故障转移**——前者切实例只会立刻撞 `409`(集群级单飞锁),后者切实例只会重复排同一批消息;这两条也各有独立超时:`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms)与 `LB_WORKER_TIMEOUT_MS`(默认 `300000` ms = 后端单条事件下界),其余路由不受影响。 -- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 -- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 -- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。 -- **重复兜底**:Worker 重试 + Telegram 重投造成的重复由 `MOD-DEDUP` 吸收;**幂等键 TTL 必须覆盖** Worker 重试与 Telegram 重投窗口。 -- **不代理 Redis/JMAP**(`C-NO-DB` / `C-REDIS-ONLY-STATE`):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。 -- **无长连接**(`C-NO-LONG-CONN`):纯请求-响应,body 一次性 `arrayBuffer` 回灌;无 WS/SSE/长轮询。 - -### 10.5 各入口差异(重要) - -| 入口 | 是否可扇出/重复 | 处理 | -|---|---|---| -| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 | -| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 | -| `POST /api/push/register` | 幂等(相同 callback URL 复用) | 已进 safelist,可走 Worker 域名;callback URL 由调用方提供、订阅记录全存共享 Redis,落到哪个后端实例都不影响 | -| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 | -| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 | - -### 10.6 聚合健康视图(`MOD-HEALTH-AGG`) - -- Worker 额外暴露**聚合端点**,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。 - -### 10.7 边界与不在本方案范围 - -- **Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`):由用户在外部解决;其短暂不可用**可接受**(Redis 全丢可由 `FLOW-RECONCILE` 从 JMAP 重建 sinceState;dedup/会话丢失只导致**少量重复通知**,符合 `NFR-NOTIFY-SLA`)。 -- **Cloudflare Worker 免费额度**:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。 -- 不引入长连接、不引入平台特定绑定(`C-NO-LONG-CONN`/`NG-SERVERLESS-BIND`)。 - -### 10.8 部署 Cloudflare Worker(`ARCH-LB-WORKER` 具体步骤) - -代码位于仓库子目录 [`cloudflare-worker/`](../cloudflare-worker/),**非本 Rust 二进制的一部分**;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(`C-NO-DB`)。 - -**前置** -- 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 `PORT`/路由同 §6.1。 -- 后端各实例 env 必须共享同一组 secret(`C-LB-SHARED-SECRETS`)。 -- 已注册 Cloudflare 账号与 Workers 计划。 - -**配置**(origin 列表当前是 `cloudflare-worker/wrangler.toml` 的 `[vars]`,见下方**明文例外**) - -```bash -cd cloudflare-worker -npm install --no-audit --no-fund -npx wrangler deploy -# 可选(带默认值,dashboard 的 Add Variable 或 CLI 二选一) -npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms -npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移) -npx wrangler secret put LB_RECONCILE_TIMEOUT_MS # 默认 320000ms,仅 POST /reconcile 生效 -npx wrangler secret put LB_WORKER_TIMEOUT_MS # 默认 300000ms,仅 POST /worker 生效 -npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms -``` - -`wrangler.toml` 里的当前值: - -```toml -[vars] -BACKEND_ORIGINS_JSON = '["https://messageweave-eu-1.motofans.club"]' -LB_VERSION = "2026.10.3" -``` - -**明文例外及其边界**:origin 只是公开可达的 https 地址,不是凭据,所以可以随仓库走(好处是 -配置在 PR 里可审,预览环境也能拿到)。一旦要放内网地址、带内嵌凭据的 URL、或不想公开的 -staging 主机名,立刻从 `[vars]` 删掉、改用 secret 注入(git 历史不可逆,删掉不等于没进过库): - -```bash -npx wrangler secret put BACKEND_ORIGINS_JSON -``` - -`BACKEND_ORIGINS_JSON` 示例(**仅允许 `https://` origin**,其它会让配置解析失败,`C-HTTPS-INBOUND`): - -```json -[ - "https://messageweave-a.example.com", - "https://messageweave-b.example.com" -] -``` - -> **必须是字符串数组,不是 `{"url":…, "weight":…}` 对象数组。** -> `cloudflare-worker/src/backends.js` 的 `parseBackendOrigin` 只认 `typeof raw === "string"` 的 origin, -> 对象项会让**整份**配置解析失败;权重也不存在——故障转移是 -> `cloudflare-worker/src/lb.js:107` 用 `rng()` 均匀选一个随机起始源再顺序尝试,没有加权。 -> 症状值得留意:配置是 `cloudflare-worker/src/index.js:105` **每个请求**重新读取并解析的, -> 所以写错不会「启动即 503」,而是**每个请求都返回 503**(`misconfigured backends`), -> 日志里也没有异常栈可看。 - -**照抄会踩的两条实测坑** - -1. **`wrangler.toml` 只能用 `#` 注释。** TOML 不认 JSDoc 的 `/** */`,`*` 会被报成 - `Unknown character "47"`,`deploy` / `secret put` / `dev` 在**读配置阶段**就失败,根本走不到你的 secret。 - 这条已修掉:`wrangler.toml` 现已全部展开成 `#` 行,`wrangler deploy --dry-run` 通过(上传 31.48 KiB)。 -2. **`npm test` 在 Node 22.23 上原先跑不起来**:脚本写的是 `node --test test/`,Node 会把 `test/` - 当成模块路径去 require,报 `MODULE_NOT_FOUND`。已改成 `node --test`(不加参数自动发现 `test/`), - 当前为 37 个用例全通过(`cloudflare-worker/test/backends.test.js` 6 / `cloudflare-worker/test/health.test.js` 5 / `cloudflare-worker/test/index.test.js` 14 / `cloudflare-worker/test/lb.test.js` 12;index 含 `/reconcile` 与 `/worker` 两条 per-route 长超时回归,lb 含 2 条不可变响应头回归,health 含 1 条 `version` 回显)。 - -**部署与验证** - -```bash -cd cloudflare-worker -npx wrangler deploy --env production -# 验证 LB 健康聚合 -curl https://.workers.dev/healthz-worker -``` - -**Telegram / Stalwart / Cron 登记的唯一 URL** 改为 `https://.workers.dev/webhook/tg` 等(`C-LB-SINGLE-REG-URL`);**各后端平台的 HTTPS 入口不再对外登记**(小平台入口可能被公网直连,故**后端鉴权不可省**,`SAF-LB-PASSTHRU`)。 - -**验证(发布前必做)** -1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。 -2. `npx wrangler deploy --env production` 后 `curl /healthz-worker` 看**响应体里的 `available`**: - 必须 `available ≥ 1` 才算接通;同时核对 `version` 等于 `wrangler.toml` 里的 `LB_VERSION`, - 不等就说明跑的还是旧部署。 - ⚠️ **只看 HTTP 200 会误判**:`/healthz-worker` 是 Worker 自己生成的(不透传后端,`cloudflare-worker/src/index.js:87-91`), - 而且 `BACKEND_ORIGINS_JSON` 缺失、格式错或为空时,`handleHealth` 会吞掉解析异常并返回 - **HTTP 200 + `{"status":"no-backends","available":0,"total":0}`**(`cloudflare-worker/src/lb.js:155` 的 catch)。 - 所以「200」本身不证明配置正确,必须看 `available`。判据速查: - `status=ok` 且 `available≥1` → 正常;`no-backends`(200)→ secret 没设好或 JSON 格式错;`down`(503)→ 后端全挂。 -3. `curl -X POST https:///webhook/tg -H "Content-Type: application/json" -d '{"test":1}'` 应得到后端返回(非 503)。 -4. `curl -X POST https:///unknown` 应得到 404;`curl -X GET https:///reconcile` 应得到 405。 -5. 日志仅出现 `method/path/origin/失败类别`,**绝不**含 header/body/secret(`SAF-LOG-PURITY`)。 - -**边界说明** -- Worker **未**实现 `/reconcile` 的 Redis 锁(`SAF-RECONCILE-LOCK`)——该锁由**后端**在 `/reconcile` 处理器内部执行;Worker 只做透传。详见 `SAF-RECONCILE-LOCK` 条目。 -- Worker **不代理** Redis、不检查数据库侧可用性(`C-NO-DB` / `C-REDIS-ONLY-STATE`)。 -- Worker **不落地** JMAP/Telegram 的 secret;Push verification 由后端通过 JMAP API 动态完成并只在 Redis 短期保存状态。 - -### 10.9 用 Cloudflare 网页版部署(Dashboard / Git 集成) - -CLI 不是唯一入口。两条网页路径: - -| 路径 | 适合 | 注意 | -|---|---|---| -| **Workers & Pages → Create application → Code Editor** | 首次部署、4 个文件手工粘贴 | 代码无 git 追溯,改动后容易和本仓库漂移 | -| **Git 集成**(Workers & Pages → 选 GitHub 仓库) | 长期最干净,push 自动部署 | 必须设**根目录**,否则跑在仓库根上找不到 `wrangler.toml` | - -Git 集成**必须改的四个字段**(Cloudflare 的默认值对不上本仓库): - -| 字段 | 默认值(❌) | 应改成(✅) | -|---|---|---| -| **根目录 / Root directory**(在「高级设置」里) | 仓库根 | `cloudflare-worker` | -| 应用名 | UI 里自填的名字 | 与 `wrangler.toml` 的 `name` 一致,否则 wrangler 会部署成**另一个名字** | -| **构建命令**(可选) | `npx wrangler deploy` | `npm test` | -| **部署命令**(必需) | `npx wrangler deploy` | **保持不动** | -| **预览命令**(勾选「启用预览构建」后为必需) | `npx wrangler preview` | `npx wrangler dev --ip 0.0.0.0 --port 8787` | - -四条理由(全部实测): - -1. **根目录是决定性的一步。** 本仓库根是 Rust 后端,Worker 在 `cloudflare-worker/` 子目录。 - 不设根目录时 Cloudflare 在仓库根跑 wrangler,那里没有 `wrangler.toml`,构建直接失败。 - 「高级设置」默认是折叠的——**展开它**,根目录就是里面的字段;设了之后, - 构建 / 部署 / 预览三个命令都在 `cloudflare-worker/` 里执行。 -2. **应用名要跟 `wrangler.toml` 的 `name` 对齐。** wrangler 部署用的是 toml 里的 `name`, - UI 上的名字不一致时,你部署出去的是 toml 里那个名字的 Worker,UI 上那个应用永远等不到部署。 -3. **构建命令推荐填 `npm test`,不必 `npm ci`。** 部署命令里的 `npx wrangler deploy` - 已经把活干完了:wrangler 自己用 esbuild 把 4 个源文件打成**一个**文件再上传(实测 - `--outdir` 产物是单个 `index.js`,33,078 字节 + sourcemap;`src/` 里零外部依赖), - 所以「构建」和「部署」在本项目是同一个动作,构建命令**留空也不会失败**。填 `npm test` - 的价值不在打包:Cloudflare 的默认安装步骤实测跑 `bun install`(CI 日志 - `bun@1.2.15, nodejs@24.18.0` → `Saved lockfile`),bun 不读 npm 的锁文件、按自己的解析 - 装依赖,仓库里已提交的锁文件被完全绕过;而 `npm test` 是 32 个测试构成的部署前门禁, - **不依赖 node_modules**(零外部依赖,空目录里也能跑通 32/32,实测 2.6 秒),所以无论 - 谁来装依赖都有效。想严格锁定 wrangler 版本就换成 - `npm ci --no-audit --no-fund && npm test`,代价是每次重新下载安装 35 个包(实测约 86 秒)。 - `npx wrangler deploy` 属于**部署命令**字段(必需,保持默认),把 deploy 塞进构建命令才是错的。 -4. **`wrangler preview` 子命令不存在。** wrangler 4.144.0 的子命令只有 `dev` / `deploy` / - `deployments`,默认那个值填了必然失败。预览命令填 `npx wrangler dev --ip 0.0.0.0 --port 8787` - (8787 是 `wrangler dev` 的默认端口,`--ip 0.0.0.0` 让 Cloudflare 的预览代理能连进来)。 - ⚠️ 但预览环境**拿不到** dashboard 里加的 secret:现在 `BACKEND_ORIGINS_JSON` 写在 - `wrangler.toml` 的 `[vars]` 里,预览因此也能跑通转发;**一旦改回 secret 注入**,预览就只剩 - 「Worker 能被拉起 + 路由表生效 + `/healthz-worker` 返回 200 但 `status` 是 `no-backends`」, - **不能**验证后端转发,转发只能看生产。(仓库里也不该有 `.dev.vars`,它只对 `wrangler dev` 生效。) - -**Node 版本不用管。** Cloudflare 环境实测给的是 `nodejs@24.18.0`,本地在 Node 24.21.0 与 -22.23.3 上都验过(32/32 通过)。**wrangler 已升到 4.144.0**:3.114.17 在 CI 里会打印 -`The version of Wrangler you are using is now out-of-date … to prevent critical errors`, -4.144.0 在该警告消失,且 wrangler 4 与 wrangler 3 一样会按 `wrangler.toml` 的 `name` -部署(toml 里的 `name = "messageweave-lb"` 保持不变即可)。lockfile 锁定 wrangler 4.144.0, -但 CI 的默认安装走 bun、不读这个锁文件,所以实际版本由 `^4.144.0` 浮动(今天正好解析到 -4.144.0);要把版本钉死,就把构建命令换成 `npm ci --no-audit --no-fund && npm test`。 - -**secret 仍在网页上配**:Workers & Pages → 你的应用 → Settings → Variables & Secrets → -**Encrypt**(不是 Plain Text)→ 加 `BACKEND_ORIGINS_JSON`。`wrangler.toml` 的 `vars = {}` -意味着所有配置都走 secret,网页版对应位置是 **Settings → General** 的 Variables(留空)与 -Encrypt(填 secret)。改 secret **即时生效,不需要重新部署**。 - -**部署前确认已 push**:Git 集成拉的是 GitHub 上的 commit,本地改动不 push 不会生效。 - -**Git 集成的一个纪律**:每次 push 到默认分支都自动部署,所以**绝不**把 `.dev.vars` 或 -任何明文 secret 提交进仓库(本仓库里没有,`.gitignore` 也排除了)。 - -**验证方式与 CLI 完全相同**,见上方「验证(发布前必做)」,尤其第 2 条: -看 `/healthz-worker` 响应体的 `available`,不要只看 HTTP 200。 - ---- - - -## 附:稳定 ID 引用索引 - -> 跨文档稳定 ID 的**唯一权威索引表**在 `docs/charter.md` §8「稳定 ID 注册表」。 -> 本文使用到的 ID(`C-*` / `NG-*` / `MOD-*` / `FLOW-*` 等)均在其中登记定义点位置与一句话说明;内容搬家时**只改 charter.md 索引表**的"文件/锚点"列,所有引用本身零改动。 +The Worker is not a Redis proxy and does not make the backend stateless; shared external Redis +remains required. It does not add a database, a long-lived connection, or a second layer of +business authentication. diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md new file mode 100644 index 0000000..8c4978d --- /dev/null +++ b/docs/deployment.zh-CN.md @@ -0,0 +1,210 @@ +# MessageWeave 部署指南 + +> [English version / 英文版 → deployment.md](deployment.md) + +使用本指南部署后端、配置邮件传递并验证通知。 +选择任何可以运行后端容器的平台,提供加密环境 +密钥,并连接到外部管理的 Redis 服务。可选的 Cloudflare Worker +gateway 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是 +在[部署平台参考](reference.zh-CN.md#9-deployment-platform-details)中。 + +## 0. Before deployment + +准备启用持久性和公共 HTTPS URL 的外部管理的 Redis 服务 +到达后端。平台或代理终止 TLS; MessageWeave 监听其中一个 +HTTP 端口(“PORT”,默认“8080”)。该过程需要两个加密的启动密钥: +`REDIS_URL` 和 `CONFIG_ENCRYPTION_KEY` (编码为 64 十六进制的随机 32 字节密钥 +字符)。切勿将真实值放入源代码管理、图像或日志中。业务设置 +通过受保护的配置API保存在Redis中;将它们添加为环境 +变量不配置服务。 + +生产环境没有本地数据库、数据量或内部调度程序。完整的安全边界 +禁止模式位于 [`charter.md`](charter.zh-CN.md) 中。 + +## 1. Deploy a backend + +### Container platform + +构建根 Dockerfile 并在平台中配置所需的机密。对于本地人来说 +容器冒烟,将 `.env.example` 复制到未跟踪的 `.env`,替换两个占位符, +然后运行: + +```sh +docker build -t messageweave:latest . +docker run --rm --env-file .env -p 8080:8080 messageweave:latest +``` + +该镜像使用 Rust 构建器和 Debian slim 运行时,以非 root 用户身份运行,并公开 +仅端口“8080”。不要安装数据或日志卷。完整图像内容位于 +[平台参考](reference.zh-CN.md#91-backend-deployment-paths)。 + +## 2. First-time configuration + +在原点打开后端的配置页面进行初始设置。使用 +`CONFIG_ENCRYPTION_KEY` 建立管理会话,然后完成一次性引导 +表单。Worker 网关不会公开 bootstrap 路由。bootstrap 和管理会话处理器位于 +`src/notify.rs:834` 与 `src/notify.rs:1187`;业务配置保存处理器位于 +`src/notify.rs:694`。后续修改可通过 Worker URL 打开受保护的配置页。 + +至少配置 Telegram 机器人令牌、目标聊天 ID、入站聊天允许列表、Stalwart +JMAP HTTPS 会话 URL 和应用程序密码、Webhook 机密和工作人员令牌。设置一个 +如果使用计划的协调,则协调令牌。仅当用户 +已明确同意外部处理。秘密值不会由 +配置API;完整的字段语义位于[配置参考](reference.zh-CN.md#5-environment-variables)中。 + +### 2.1 Optional remote diagnostics + +在生产中保留“DEBUG_ENABLED”和“DEBUG_TOKEN”未设置,除非远程诊断 +刻意需要的。两者都必须配置为挂载诊断路由,并且这些路由 +只能通过后端源访问。诊断表面和响应详细信息 +记录在[参考](reference.zh-CN.md#3-backend-routes)中。 + +## 3. Build and run the Docker image + +根 Dockerfile 是容器部署路径。它有独立的 Rust 构建器和 +Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-docker-image-details)。 + +## 4.1 注册 Telegram 和 Stalwart 回调 + +保存业务配置后,在配置页的“注册 Telegram 和 Stalwart 回调”区域操作。输入接收回调的公开 HTTPS origin:启用 Worker 时填写 Worker 地址,否则填写后端地址。页面会向 Telegram 注册 `/webhook/tg`,并向 Stalwart 注册 `/push/jmap`。凭据保留在后端,浏览器只提交回调 URL。Telegram 和 Stalwart 注册处理器分别位于 `src/notify.rs:1439` 与 `src/notify.rs:1315`。 + +Telegram 使用已保存的 webhook secret 和 `allowed_updates: ["message"]`。注册后,Stalwart 会自动完成验证回调和 verification-code 写回。可以安全地重复注册;更改回调 origin 会更新 Telegram webhook,并为新的 URL 创建 Stalwart subscription。 + +## 5. Runtime configuration + +将启动时进程配置与 Redis 驻留业务配置分开。 +进程启动时仅需要“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。完整的 +业务字段列表在【配置参考】(reference.md#5-environment-variables)中。 + +## 6. Short-request runtime and scheduling + +该服务一次处理一个请求,并且不保留 JMAP 事件流、Telegram +长轮询,或打开后台计时器。 Telegram/JMAP 推送提供快捷路径;的 +外部调度程序调用下面描述的端点。 + +### 6.3 Schedule queue draining and reconciliation + +后端没有内部调度程序。外部 cron、计划作业或平台计时器 +必须调用 [`scripts/cron-drain.sh`](../scripts/cron-drain.sh);否则排队的通知 +仍然未交付。当设置“MW_RECONCILE_TOKEN”时,脚本调用“/reconcile”,然后耗尽 +工作队列。 + +提供`MW_APP_URL`和`MW_WORKER_TOKEN`;当对帐时设置“MW_RECONCILE_TOKEN” +已启用。将令牌存储在调度程序的秘密管理器中。对于单次运行: + +使用调度程序的秘密管理器注入的令牌,运行: + +```sh +MW_APP_URL='https://' bash scripts/cron-drain.sh --once +``` + +调节节奏通常为 5-10 分钟。两个成功的端点都返回“204” +身体空虚;不要将其视为错误。脚本的“--diagnose”模式有帮助 +将服务配置问题与调度问题分开。其论点和 +操作模式列在[调度程序参考](reference.zh-CN.md#93-callback-registration-and-scheduled-work)中。 + +### 6.4 Reliability behavior + +Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应 +直接。 `/reconcile` 和 `/worker` 具有更长的每条路由超时,并且不会故障转移到 +第二个后端,避免重复工作。后端队列处理至少一次并且依赖 +关于 Redis 支持的幂等性。请参阅路线和状态参考以了解完整的行为。 + +### 6.5 Delivery objective + +该服务允许短暂的通知延迟,并以至少 99.9% 的通知率为目标 +可用性。 Redis 可用性和外部调度间隔超出范围 +应用程序的控制;准确的交付保证在“NFR-NOTIFY-SLA”中进行了描述,并且 +项目章程中的“NFR-RECONCILE-INTERVAL”。 + +## 7. Health checks + +使用 `/healthz` 检查进程存活(`src/notify.rs:118`),使用 `/ready` 检查依赖就绪 +(`src/notify.rs:193`)。验证步骤见[§8.1](#81-verify-the-deployment)。 + +## 8.1 Verify the deployment + +首先检查后端: + +|请求|预期结果|含义| +|---|---|---| +|`GET /healthz`|`200`|进程存活。| +|`GET /ready`|`200`|配置、Redis、JMAP 和 Telegram 探针均就绪;依赖故障返回 `503`。| +|`GET /api/status`|`{"ready":true}`|必需的启动配置已就绪;`missing` 字段列出缺失变量名。| + +启用 Worker 网关后,还请求“GET /healthz-worker”。要求 +`available >= 1` 并检查 `version` 是否与部署的 `LB_VERSION` 匹配;仅 HTTP 200 +可能意味着“无后端”。 `cloudflare-worker/` 目录的测试和部署命令是 +在其自述文件中描述。 + +将“/help”发送到配置的 Telegram 聊天并确认回复。然后发送一封新电子邮件并 +确认外部调度程序耗尽其通知。健康的“/healthz”只能证明 +进程活跃度;它并不能证明 Redis 或上游任一者可用。 + +## 8.2 Docker image details + +根 Dockerfile 的构建器使用 `rust:1-slim-bookworm`;运行时间是 +`debian:bookworm-slim`。运行时安装 CA 证书、`tini`、`curl`、`procps`、 +`iproute2`、`jq` 和 `netcat-openbsd` 创建 `messageweave` 系统用户,设置 +`PORT=8080`,并通过 `tini` 启动二进制文件。它没有“VOLUME”声明并且确实 +不启动Redis。这些信息描述仓库提供的 Docker 镜像;其他平台可以使用自己的构建器和运行时镜像。 + +## 9. Confirmed deployment choices + +这些部署决定已经确定;它们不是每次安装的先决条件。 + +### 9.1 Public ingress and state + +后端使用外部 HTTPS 入口和外部 Redis。 Telegram、JMAP 推送和 +计划的请求使用选定的公共 URL。当Worker网关启用后,注册 +Telegram 和 Stalwart 的稳定 Worker URL;不要在网关处公开凭据。 +本节中的稳定 ID 指向项目章程的注册表。 + +## 10. Optional Cloudflare Worker gateway + +Worker 为多个后端源提供一个公共 HTTPS 入口点。所有后端 +必须共享相同的Redis和业务配置。 Worker 仅转发其固定的 +路由白名单; bootstrap 和 `/debug/*` 仍然仅限原始。详细路线事实在 +[网关路由矩阵](reference.zh-CN.md#4-gateway-vs-backend-route-matrix)。 + +### 10.1 Topology + +Telegram webhooks、Stalwart 推送回调和外部调度可以使用稳定的 Worker +网址。 Worker 转发到后端 HTTPS 源;它不连接到 Redis 或 JMAP。 + +### 10.2 Multi-instance prerequisites + +多个后端可以共享流量,因为请求状态存储在 Redis 中,重复 +交付已进行重复数据删除,协调修复了错过的推送事件。无粘性会话 +或需要更改应用程序代码。 + +### 10.3 Trust model + +Worker 传递请求标头和正文。仍需要后端身份验证 +因为后端源可能是直接可达的。每个实例必须使用相同的共享 +业务凭证和 Redis 状态。 + +### 10.4 Routes and failover + +Worker 使用“404”拒绝未注册的路由,并使用“405”拒绝错误的方法。普通 +请求最多尝试“LB_MAX_ATTEMPTS”来源(默认两个),并且只有超时或“5xx” +触发故障转移。 `/reconcile` 和 `/worker` 是具有较长超时时间的单次尝试路由 +覆盖。使用[参考](reference.zh-CN.md#4-gateway-vs-backend-route-matrix)中的路由矩阵和调整值。 + +### 10.5 Callback URLs + +使用网关时,将Telegram的webhook和Stalwart的push回调设置给Worker +域。外部调度程序可以对转发路径使用相同的 URL。引导程序和 +诊断端点仍然需要后端源。 + +### 10.6 Gateway health + +`GET /healthz-worker` 报告 Worker 版本和后端可用性。两者都使用 +用于验证部署的“可用”和“版本”; `status: no-backends` 可以返回 +当未配置有效源时,HTTP 200。 + +### 10.7 Boundaries + +Worker不是Redis代理,不会使后端无状态;共享外部Redis +仍然需要。它不添加数据库、长期连接或第二层 +商业认证。 diff --git a/docs/design.md b/docs/design.md index 4aa2888..3d050ed 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,73 +1,75 @@ -# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计 +# Stalwart JMAP ↔ Telegram Bot — Rust solution design -> 状态:**设计与实现均已落地**(代码见仓库 `src/`、`web/`、`cloudflare-worker/`;本文同时保留设计决策与选型评估的原始记录,阶段 1/2/4 的表述属历史计划,当前进度见 §10.5) -> 作者:Cowork(team: MessageWeave) -> 日期:2026-09-21 -> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审 +> [中文版本 / Chinese version → design.zh-CN.md](design.zh-CN.md) + +> Status: **Design and implementation have been implemented** (The code can be found in the warehouses `src/`, `web/`, `cloudflare-worker/`; this article also retains the original records of design decisions and selection evaluations. The description of stages 1/2/4 belongs to the historical plan, and the current progress can be found in §10.5) +> Author: Cowork (team: MessageWeave) +> Date: 2026-09-21 +> Target readers: Codex CLI (lead), follow-up AI coding agent, end-user review > -> **相关文档**(职责分离,避免重复堆砌): -> - [docs/charter.md](charter.md) — 项目章程:项目目标、技术选型、安全不变量、实现阶段、禁止事项、测试验收与稳定 ID 注册表 -> - [AGENTS.md](../AGENTS.md) — 通用、语言无关的代码编写与环境构建规范(不含项目专属内容) -> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与仍需确认项 +> **Related documents** (separation of responsibilities to avoid duplication): +> - [docs/charter.md](charter.md) — Project charter: project goals, technology selection, security invariants, implementation phases, prohibited matters, test acceptance and stable ID registry +> - [AGENTS.md](../AGENTS.md) — Universal, language-independent code writing and environment building specifications (excluding project-specific content) +> - [docs/deployment.md](deployment.md) — Deployment, operation, maintenance and release: general HTTPS-only Docker container, Secrets, Redis (the only carrier of status), short request Webhook/Push/reconciliation routing, health check, CI release and items that still need to be confirmed > -> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维与 Docker/通用容器平台细节已移至 `deployment.md`;项目约束与安全不变量在 `docs/charter.md`;通用、语言无关的代码编写与环境构建规范在 `../AGENTS.md`。 +> This document only retains content directly related to **product behavior, code architecture, module interfaces, state machines, data flow, error handling, testing and implementation phases**. Deployment operations and Docker/universal container platform details have been moved to `deployment.md`; project constraints and security invariants are in `docs/charter.md`; general, language-agnostic code writing and environment building specifications are in `../AGENTS.md`. --- -## 0. 文档导航 +## 0. Document navigation -1. [任务与范围](#1-任务与范围) -2. [jmap-client 能力分析](#2-jmap-client-能力分析) -3. [认证与邮箱操作适配](#3-认证与邮箱操作适配) -4. [Telegram 渠道实现选型](#4-telegram-渠道实现选型) -5. [整体架构与数据流](#5-整体架构与数据流) -6. [模块划分](#6-模块划分) -7. [配置与安全](#7-配置与安全) -8. [错误处理与可观测性](#8-错误处理与可观测性) -9. [测试策略](#9-测试策略) -10. [分阶段实施计划](#10-分阶段实施计划) -11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-历史问题与决策归档产品架构类均已有结论) -12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退) +1. [Task and Scope](#1-Task and Scope) +2. [jmap-client capability analysis](#2-jmap-client-capability analysis) +3. [Adaptation of authentication and email operations] (#3-Adaptation of authentication and email operations) +4. [Telegram channel implementation selection](#4-telegram-channel implementation selection) +5. [Overall architecture and data flow](#5-Overall architecture and data flow) +6. [Module Division](#6-Module Division) +7. [Configuration and Security](#7-Configuration and Security) +8. [Error Handling and Observability](#8-Error Handling and Observability) +9. [Test Strategy](#9-Test Strategy) +10. [Phase-based implementation plan](#10-Phase-based implementation plan) +11. [Historical issues and decision-making archives (product/architecture category, both have been concluded)] (#11-Historical issues and decision-making archives, product architecture category have been concluded) +12. [AI auxiliary capabilities: architecture, confirmation threshold, failure fallback] (#12-ai-auxiliary capability architecture confirmation threshold failure fallback) -> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/opengaps.md`**。 +> Deployment/platform decisions confirmed (Single Account, App Password+Basic, Redis Hosting+AOF, Platform HTTPS URL, External Cron) and **all converged archive** (including `Q-DEP-A`/`Q-DEP-B`, see the Confirmed Decisions section of `docs/deployment.md`); **Unfinished code gaps can be found in `docs/opengaps.md`**. --- -## 1. 任务与范围 +## 1. Task and Scope -### 1.1 目标 -构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**: +### 1.1 Goals +Build a Telegram robot written in Rust as a **personal email assistant** for Stalwart JMAP mailbox: -- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。 -- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。) -- 单用户或多账户部署(默认面向单账户自托管场景)。 +- Query/read emails, view folders, send emails, manage keywords, etc. through Telegram commands. +- Leverage JMAP's **Push HTTPS callback** (+ external Cron reconciliation) to **proactively push to Telegram** when new emails arrive. (EventSource/SSE/long polling is non-target, see deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`.) +- Single-user or multi-account deployment (the default is for single-account self-hosting scenarios). -### 1.2 范围(本阶段) -- ✅ 调研 + 方案设计(本文档) -- ❌ 不创建/修改项目代码、不初始化 cargo 工程 -- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项 +### 1.2 Scope (this stage) +- ✅ Research + Plan Design (this document) +- ❌ Do not create/modify project code or initialize cargo project +- ✅ Output detailed plans for lead and user review, marking key uncertain items -### 1.3 调研依据 -- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研) -- 项目目录:`/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`) +### 1.3 Research basis +- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client) (main branch, as of this research) +- Project directory: `/home/okabe/Repo/messageweave/` (see §10.0 and `../AGENTS.md §3.3` for tool chain requirements) --- -## 2. jmap-client 能力分析 +## 2. jmap-client capability analysis -来源:直接阅读 `stalwartlabs/jmap-client` 仓库的 `stalwartlabs/jmap-client/src/lib.rs`、`stalwartlabs/jmap-client/src/client.rs`、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。 +Source: Read directly from the `stalwartlabs/jmap-client` warehouse `stalwartlabs/jmap-client/src/lib.rs`, `stalwartlabs/jmap-client/src/client.rs`, `stalwartlabs/jmap-client/src/email/`, `stalwartlabs/jmap-client/src/email_submissi on/`, `stalwartlabs/jmap-client/src/event_source/`, `stalwartlabs/jmap-client/Cargo.toml`, `stalwartlabs/jmap-client/README.md`, `stalwartlabs/jmap-client/examples/`. -### 2.1 crate 概况 -| 项 | 值 | +### 2.1 crate overview +| item | value | |---|---| -| crate 名 | `jmap-client`(crates.io) | -| 协议覆盖 | JMAP Core (RFC 8620)、Mail (RFC 8621)、WebSocket (RFC 8887)、Sieve (draft-12) | -| 异步运行时 | tokio + reqwest | -| 许可证 | Apache-2.0 OR MIT | -| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ | -| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 | +| crate name | `jmap-client` (crates.io) | +| Protocol coverage | JMAP Core (RFC 8620), Mail (RFC 8621), WebSocket (RFC 8887), Sieve (draft-12) | +| Asynchronous runtime | tokio + reqwest | +| License | Apache-2.0 OR MIT | +| `forbid(unsafe_code)` | Yes (stated at top of lib.rs)✅ | +| Default features | **0.4.2 Tested `default = ["async", "websockets", "aws_lc_rs"]`** (including WebSocket stack `tokio-tungstenite`). ⚠️ `default-features = true` will **implicitly enable WebSocket**, violating `C-NO-LONG-CONN`. **This project actually uses `default-features = false, features = ["async", "rustls"]` (without `websockets`)**, subject to `Cargo.toml`. | -### 2.2 模块结构(`src/`) +### 2.2 Module structure (`src/`) ``` lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举 client.rs Client:认证、connect、session、send_request、event_source、ws @@ -86,28 +88,28 @@ event_source/ SSE 流:mod / parser / stream client_ws/ WebSocket 客户端(feature = "websockets") ``` -### 2.3 关键能力映射到本 Bot -| Bot 需求 | jmap-client API | 备注 | +### 2.3 Key capabilities mapped to this Bot +| Bot requirements | jmap-client API | Remarks | |---|---|---| -| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities | -| 列文件夹 | `mailbox_query` / `mailbox_get` | 带 `role` 可识别 INBOX/重要/草稿等 | -| 列邮件 | `email_query`(Filter + Comparator + anchor 分页) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… | -| 读邮件正文 | `email_get` + `Property` 选择 | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` | -| 读附件 | `Blob/get`(blobId)或 `email_parse` | 大附件需分片/流式下载 | -| 发邮件(2 步) | ① `email_set`/`email_import` 建 draft ② `email_submission_set` 发送 | submission 关联 identityId | -| 删除/归档 | `email_set`(keywords `$seen`/`$flagged`)、`mailbox_destroy` | JMAP 无真"删除",靠 keyword/搬家 | -| 搜索 | `email_query`(`Filter::text`)+ `SearchSnippet/get` 高亮 | `Filter` 是 serde 单标签枚举,**无 comparator 语法**;`SearchSnippet/get` 只返回 `emailId`/`subject`/`preview`,**无 `bodyProperties`/`parts`**,正文级高亮在锁定版本 `0.4.2` 做不到(降级为纯 ID 列表) | -| 实时通知(Push + 对账兜底) | Push HTTPS 回调 → `StateChange`;外部 Cron 调用 `/reconcile` 使用 `Email/changes` 补差 | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);Push 不是唯一可靠来源 | -| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 | +| Login/Session | `Client::new().credentials(...).connect(url)` | Supports Basic and Bearer; connect resolves session URL, capabilities | +| Column folder | `mailbox_query` / `mailbox_get` | With `role` to identify INBOX/Important/Draft, etc. | +| List emails | `email_query` (Filter + Comparator + anchor paging) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… | +| Read email text | `email_get` + `Property` selection | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` | +| Read attachments | `Blob/get` (blobId) or `email_parse` | Large attachments need to be downloaded in fragments/streaming | +| Send email (2 steps) | ① `email_set`/`email_import` create draft ② `email_submission_set` send | submission associate identityId | +| Delete/Archive | `email_set` (keywords `$seen`/`$flagged`), `mailbox_destroy` | JMAP has no real "delete", relying on keyword/move | +| Search | `email_query` (`Filter::text`) + `SearchSnippet/get` highlighting | `Filter` is a serde single-label enumeration, **no comparator syntax**; `SearchSnippet/get` only returns `emailId`/`subject`/`preview`, **no `bodyProperties`/`parts`**, body-level highlighting in locked version `0.4.2` can't do it (downgraded to a pure list of IDs) | +| Real-time notification (Push + reconciliation) | Push HTTPS callback → `StateChange`; external Cron calls `/reconcile` and uses `Email/changes` to make up the difference | Requires public HTTPS entrance (deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`); Push is not the only reliable source | +| SSE / WebSocket (non-target) | `event_source` / `client_ws` | This deployment **does not use** (deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`); only crate capabilities are listed for investigation | -### 2.4 认证机制(`stalwartlabs/jmap-client/src/client.rs`) -- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。 -- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。 -- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。 -- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。 -- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。 +### 2.4 Authentication mechanism (`stalwartlabs/jmap-client/src/client.rs`) +- `Credentials::Basic { username, secret }` — username/password, natively supported by Stalwart. +- `Credentials::Bearer { token, .. }` — OAuth2 access_token; optional `refresh_token` + `refresh_url` + `refresh_grace`, the client will automatically refresh before expiration. +- `connect()` Function: GET session URL → Parse `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`, cache account_id. +- Support custom `reqwest::Client` (`Client::new().client(reqwest_client)`): can inject proxy, TLS configuration, timeout, UA. +- Support `accept_long_responses` / `event_source(ping, ..)` for long connection keep-alive. -### 2.5 错误模型(`Error` 枚举) +### 2.5 Error model (`Error` enumeration) ``` Transport(reqwest::Error) 网络/TLS/超时 Parse(serde_json::Error) 序列化 @@ -118,60 +120,60 @@ Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLim Set(SetError) /set 级别错误(每条记录的 creation/update/destroy 失败) WebSocket(...) ws 错误(feature 开启时) ``` -- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。 +- `MethodErrorType` fine-grained: `ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → Can directly drive Bot’s retry/current limiting/state reset strategy. -### 2.6 实时通道决策(已定,见 deployment.md) +### 2.6 Real-time channel decision-making (determined, see deployment.md) -> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。 -> 当前通道 = JMAP Push HTTPS 回调(先由管理员通过 `POST /api/push/register` 显式注册,见 §7.3)+ 外部 Cron `/reconcile` 对账兜底。Push 订阅不会自动创建,不能把 Push 当作唯一可靠来源。 -> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。 +> Deployment target = **Generic HTTPS-only Docker, no long connections** (deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`). +> Current channel = JMAP Push HTTPS callback (first explicitly registered by the administrator via `POST /api/push/register`, see §7.3) + external Cron `/reconcile` reconciliation. Push subscriptions are not created automatically, and Push cannot be regarded as the only reliable source. +> EventSource/SSE and WebSocket are both marked as **non-target** (`NG-POLLING-SSE`). Only the following table is retained for reference for crate capability research. -| 通道 | 是否采用 | 备注 | +| Channel | Whether to use | Remarks | |---|---|---| -| **Push Subscription(HTTP 回调)** | 已接入,需显式注册 | 短请求模型,契合无状态 + 无长连接;`POST /api/push/register` 创建订阅,`/push/jmap` 自动完成 Stalwart 验证回写 | -| EventSource/SSE | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突(`NG-POLLING-SSE`) | -| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突;且需服务端支持 | +| **Push Subscription (HTTP callback)** | Accessed, explicit registration required | Short request model, suitable for stateless + no long connection; `POST /api/push/register` creates subscription, `/push/jmap` automatically completes Stalwart verification writeback | +| EventSource/SSE | ❌ non-target | long connection, conflicts with `C-NO-LONG-CONN` (`NG-POLLING-SSE`) | +| WebSocket (RFC 8887) | ❌ Non-target | Long connection, conflicts with `C-NO-LONG-CONN`; and needs server support | -对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。 +Reconciliation: External HTTPS Cron periodically adjusts `/reconcile` (deployment.md `FLOW-RECONCILE`), uses `Email/changes` + Redis `sinceState` to make up for the difference, and also serves as cursor reconstruction after Redis is lost. --- -## 3. 认证与邮箱操作适配 +## 3. Authentication and email operation adaptation -### 3.1 认证策略(已确认:App Password + Basic) -- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。 -- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。 -- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。 -- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 -- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。 +### 3.1 Authentication Strategy (Confirmed: App Password + Basic) +- **Confirm adoption** (`C-AUTH-APP-BASIC`): `Credentials::Basic` (Stalwart account email + **App Password**). App Password can be revoked/expired independently without using a master password. Configuration injection, no code required. +- **Not used**: OAuth/OIDC `Bearer` (increases complexity when there is no OIDC requirement, do not select); master password Basic. +- **Security**: The password is only saved in the configuration or key manager; wrapped with `secrecy::SecretString` during runtime, the log will never print plain text (see §7.2). +- For runtime injection methods, see deployment.md (`C-NO-SECRET-IN-IMAGE`). +- **Single Account** (`REQ-SINGLE-ACCOUNT`): This instance only connects to one Stalwart account; multiple accounts = deploy multiple bot instances. -### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义) -建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词: +### 3.2 Mailbox operation adaptation layer (JMAP ↔ Bot semantics) +It is recommended to encapsulate a layer of domain semantics in the `domain::jmap` module and only expose business verbs to the upper layer: -| Bot 动词 | 封装函数 | 内部 JMAP | +| Bot verb | Wrapper function | Internal JMAP | |---|---|---| -| `list_folders()` | → `Vec` | `mailbox_query` + `mailbox_get`(缓存 role→id) | -| `list_emails(folder, page)` | → `Vec` | `email_query`(anchor 分页) + `email_get` 不传 `properties`(取服务器默认全量,含 `From`;列表页也要显示发件人) | -| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]);**多 part 原文**(`REQ-JMAP-RAW-MULTIPART`):按 `text_body` 顺序筛选"有 `part_id` 且 `bodyValue`"的 part 后**拼接**为 `text`;若无可用部分 → 返回**明确错误**(不静默返回空串)。附件用 `Blob/get` | -| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) | +| `list_folders()` | → `Vec` | `mailbox_query` + `mailbox_get` (cache role→id) | +| `list_emails(folder, page)` | → `Vec` | `email_query`(anchor paging) + `email_get` does not pass `properties` (take the server's default full value, including `From`; the list page must also display the sender) | +| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]); **Multiple part original text** (`REQ-JMAP-RAW-MULTIPART`): Filter "parts with `part_id` and `bodyValue`" in order of `text_body` The last **splicing** is `text`; if there is no available part → return **clear error** (do not silently return an empty string). Attachments use `Blob/get` | +| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` Create draft ② `email_submission_set`(onSend) | | `set_flag(id, keyword)` | → () | `email_set` keywords | -| `search_emails(account_id, query, limit)` | → `Result, JmapError>` | `email_query`(`Filter::text`,`limit` 封顶 100)取 ID + `SearchSnippet/get` 取高亮;后者不支持(`unknownMethod`/超时)时**降级**返回空 snippets 而非报错,由调用方渲染纯 ID 列表 | +| `search_emails(account_id, query, limit)` | → `Result, JmapError>` | `email_query` (`Filter::text`, `limit` capped at 100) gets the ID + `SearchSnippet/get` gets the highlight; when the latter does not support (`unknownMethod`/timeout) **downgrade** returns empty snippets Instead of reporting an error, the caller renders a pure ID list | -> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。 +> Design points: `anchor`+`position` paging of `email_query` is JMAP standard practice and is more stable than traditional offset; `sinceState` + `changes` is used for incremental synchronization to avoid repeated pulling of the entire volume. --- -## 4. Telegram 渠道实现选型 +## 4. Telegram channel implementation selection -Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAGE4`),**不引入第三方 Bot 框架**。 -评估过 `teloxide` 但**未采用**:本项目只用 Bot API 的少数调用,不值得为它引入 dptree 调度与自带会话中间件这两层抽象;`throttle` 与 Redis 会话能力用 `src/channel.rs` 的重试与外部 Redis TTL 各实现了一部分。 -候选对比(`teloxide` / `grammers` / 旧 `telegram-bot`)、当初倾向 `teloxide` 的 6 条理由、feature 集计划与 `teloxide-core` 降级方案,全部记录在 `docs/retired.md`。 +Telegram channel is self-developed and implemented using `reqwest` in `src/channel.rs` (`ARCH-DEPS-STAGE4`), **without introducing third-party Bot framework**. +Evaluated `teloxide` but **not adopted**: This project only uses a few calls of the Bot API, and it is not worth introducing the two layers of abstraction of dptree scheduling and built-in session middleware; `throttle` and Redis session capabilities are partially implemented using `src/channel.rs` retries and external Redis TTL. +Candidate comparison (`teloxide` / `grammers` / old `telegram-bot`), 6 reasons why we originally preferred `teloxide`, feature set plan and `teloxide-core` downgrade plan, all recorded in `docs/retired.md`. --- -## 5. 整体架构与数据流 +## 5. Overall architecture and data flow -### 5.1 高层拓扑 +### 5.1 High-level topology ``` ┌───────────────────────────────────────────────────┐ │ Telegram │ @@ -205,47 +207,47 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG │ worker: Email/changes → 通知 → 发往 TG → 推进游标│ └───────────────────────────────────────────────────┘ ``` -> 部署形态 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`): -> Telegram 仅 Webhook;JMAP 实时仅 Push 回调;慢任务异步入 Redis Streams;对账由**外部 HTTPS Cron** 触发。 -> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。 -> 渠道解耦(见 §5.2):领域层与渠道层以领域 Command / Notification 交互,领域层不出现任何具体渠道 SDK 类型;钉钉/飞书仅保留扩展位,不提前实现。 +> Deployment form = **General HTTPS-only Docker, no long connection** (deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`): +> Telegram is Webhook only; JMAP real-time is Push callback only; slow tasks are asynchronously stepped into Redis Streams; reconciliation is triggered by **External HTTPS Cron**. +> EventSource/SSE and long polling are non-targets (`NG-POLLING-SSE`/`NG-LONG-POLLING`). +> Channel decoupling (see §5.2): The domain layer interacts with the channel layer through domain Command/Notification, and no specific channel SDK type appears in the domain layer; DingTalk/Feishu only retains extension bits and does not implement them in advance. -### 5.2 渠道抽象与多通道扩展策略 -- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。 -- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。 -- **抽象(不过度设计)**:不预留空 trait。`src/channel.rs` 曾有三个零实现的 - `#[expect(dead_code)]` 占位 trait(`Channel` / `Notifier` / `MessageAdapter`,注释自述 - 「稳定ID+阶段0占位」),**已删除**(登记见 `docs/retired.md`)——实际 Telegram 出站走 - `channel::telegram::TelegramClient::send_text`(`reqwest` 自研),由 `worker.rs` 的 - `MetadataWorker` 与 `notify.rs` 直接持有,**从未经过这三个 trait**。命令解析在 - `parse_intent`(`src/worker.rs`),渲染逻辑散在 `src/worker.rs` / `src/notify.rs`。 - 领域与渠道之间传递的是领域 `Notification`(`src/domain.rs`),渠道只在边缘做适配。 -- **约束**: - - `jmap` / `llm` / 意图状态机 / `notify::core` 的公开接口只接受/返回领域类型; - - 领域层不得出现具体渠道 SDK 类型(渠道层当前不引入第三方 Bot 框架,见 §4); - - 新增渠道 = 新 adapter + 装配,不改领域层。真需要抽象时**带实现一起加**,不留空 trait。 -- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。 +### 5.2 Channel abstraction and multi-channel expansion strategy +- **Status quo**: The first (and currently only) channel is Telegram. The goal is to retain the ability to expand DingTalk/Feishu in parallel, but not to achieve it ahead of time. +- **Principle**: Decoupling the domain layer and channel layer - Mail/JMAP/AI/intention state machine ** must not rely on Telegram type**. +- **Abstract (not over-engineered)**: No empty traits are reserved. `src/channel.rs` had three zero implementations + `#[expect(dead_code)]` Placeholder trait (`Channel` / `Notifier` / `MessageAdapter`, comment readme + "Stable ID + Phase 0 placeholder"), **Deleted** (see `docs/retired.md` for registration) - Actual Telegram outbound + `channel::telegram::TelegramClient::send_text` (self-developed by `reqwest`), by `worker.rs` + `MetadataWorker` and `notify.rs` are held directly and are never passed through these three traits. The command is parsed in + `parse_intent` (`src/worker.rs`), rendering logic is scattered in `src/worker.rs` / `src/notify.rs`. + What is passed between the domain and the channel is the domain `Notification` (`src/domain.rs`), and the channel only adapts at the edge. +- **Constraints**: + - The public interface of `jmap` / `llm` / intent state machine / `notify::core` only accepts/returns domain types; + - Specific channel SDK types must not appear in the domain layer (the channel layer does not currently introduce third-party Bot frameworks, see §4); + - Add new channel = new adapter + assembly, do not change the domain layer. When abstraction is really needed, add it with the implementation and leave no empty traits. +- **What not to do**: Do not define a polymorphic configuration registry, do not abstract the "channel capability matrix" in advance, do not build a plugins mechanism; evolve on demand (YAGNI). -### 5.3 运行模型(短请求,无长连接) -- 单一二进制 `message-weave`,`#[tokio::main]`。 -- 启动时: - 1. 加载配置(`config::Config`)。 - 2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。 - 3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`);`/ready` 已做端到端探测(配置 + Redis + 出站只读探测 JMAP session `GET` 与 TG `getMe`,各 3s、并行,最坏约 3s)。 - 4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。 - 5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。 -- 通知发送与命令处理共享 `Arc`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。 -- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。** -- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**: - - **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。 - - **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。 - - **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。 - - **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。 - - **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。 -- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。 -- **当前现状**:完成配置引导、HTTP 入口和写入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed);JMAP session、Email/changes、PushSubscription create/update、Redis Streams worker 和 `/reconcile` 已接入。`/reconcile` 与 Push 闭环仍需在真实 Stalwart、Redis、Telegram 环境完成端到端验收,不能仅凭本地门禁宣称生产链路已验证。 +### 5.3 Running model (short request, no long connection) +- Single binary `message-weave`, `#[tokio::main]`. +- On startup: + 1. Load configuration (`config::Config`). + 2. Construct `JmapService` (`Client::connect` completes session parsing, account_id caching, mailbox role→id mapping warm-up); sinceState is restored from external Redis (`MOD-SINCESTATE`). + 3. Start the **HTTP portal** (axum, single port `PORT`): `/webhook/tg`, `/push/jmap`, `/reconcile`, `/healthz`, `/ready`; three of the write paths first pass the `SAF-AUTH-*` portal authentication (fail-closed, §7.3), `/healthz`, `/ready` For the public probe (`SAF-PROBE-PUBLIC`); `/ready` has done end-to-end detection (configuration + Redis + outbound read-only detection JMAP session `GET` and TG `getMe`, each 3s, parallel, about 3s at worst). + 4. Start **Redis Streams worker** (background task) to consume Push events → `Email/changes` → Notification → Send to TG → Push sinceState → XACK. + 5. **Does not hold any long connections and does not build self-timers** (`C-NO-LONG-CONN`); reconciliation is triggered by **external HTTPS Cron** `/reconcile` (`FLOW-RECONCILE`). +- Notification sending and command processing share `Arc`, and internal `tokio::sync::RwLock` protects the variable cache; cross-request status always falls to external Redis (`C-REDIS-ONLY-STATE`). +- **Multiple instances and load balancing (deployment form, `ARCH-LB-WORKER`)**: Since the state is all external Redis (`C-REDIS-ONLY-STATE`) and the delivery is idempotent (`MOD-DEDUP`), **the same image can be instantiated across multiple serverless platforms**, free Cloudflare Worker is used as the only entrance and failover (`C-LB-SINGLE-REG-URL`); `/reconcile` Use Redis to lock a single instance (`SAF-RECONCILE-LOCK`), and Streams to automatically amortize using the same consumer group (`MOD-STREAMS-GROUP`). See deployment.md §10 for details. **Field/channel logic does not need to be changed. ** +- **Production redline (`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`, see deployment.md §0 / §8.2)**: + - **Production uses no database** (`C-NO-DB`): No SQLite/Postgres/MySQL/embedded database; Redis is the only production state store. + - **Disable local file/directory writing** (`C-NO-LOCAL-WRITE`): no log files, no data files, no temporary cache, no local volumes mounted. + - **Log only writes to stdout/stderr** (`C-LOG-STDOUT-ONLY`): The container/platform is responsible for collecting and placing disk; disable the file log backend. + - **Log and Redis write content constraints** (`SAF-LOG-PURITY`): Only structured events, counts, timestamps, desensitized summaries; **Prohibited** key original text, JMAP email body, AI request/response, attachment content. + - **It is prohibited to rely on in-process status for production recovery** (`C-NO-STATEFUL-RECOVERY`): Restart and continuation (deduplication, sinceState, Streams breakpoints, circuit breaker counts, sessions) are all implemented by external Redis + JMAP reconciliation; the in-process cache is only for performance optimization, and the loss must be safe and reentrant. +- Graceful exit: signal + `CancellationToken`, flush Redis Streams pending entries before exiting (**Do not write local files**, `C-NO-LOCAL-WRITE`). +- **Current status**: Configuration guidance, HTTP entry and write entry authentication completed (`R1`/`SAF-AUTH-*`, fail-closed); JMAP session, Email/changes, PushSubscription create/update, Redis Streams worker and `/reconcile` have been connected. `/reconcile` and the Push closed loop still need to complete end-to-end acceptance in the real Stalwart, Redis, and Telegram environments. You cannot claim that the production link has been verified based on local access control alone. -### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL) +### 5.4 Data flow: new email push (critical path, FLOW-NEW-MAIL) ``` Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state}) → 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK @@ -256,11 +258,11 @@ Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_sta → 更新 sinceState(写外部 Redis)→ XACK → 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发 ``` -> 关键:`Email/changes` 在 `created` 列出新建邮件 id,避免全量 `email_query`;`sinceState` 存外部 Redis(`C-REDIS-ONLY-STATE`),Redis 丢失时由对账从 JMAP 重建,**事实源在 JMAP**。 -> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 `docs/charter.md §3` 安全边界 `SAF-NOTIFY-META`)。 -> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。 +> Key: `Email/changes` lists the new email id in `created` to avoid full `email_query`; `sinceState` is stored in external Redis (`C-REDIS-ONLY-STATE`). When Redis is lost, it will be reconstructed from JMAP by reconciliation, and the **fact source is in JMAP**. +> Notifications only contain sender/subject/time (+number of attachments), the text never appears in the notification (see `docs/charter.md §3` Security Boundary `SAF-NOTIFY-META`). +> No long connection: Push is a short request callback, reconciliation is triggered by **external HTTPS Cron** (`C-NO-LONG-CONN`), and is not a self-sustaining timer in the container. -### 5.5 数据流:命令 `/read `(长邮件分支) +### 5.5 Data flow: command `/read ` (long mail branch) ``` TG /read 3 → handler 取会话里的 folder+page 游标 → JmapService.list_emails(folder, page=3) @@ -273,32 +275,32 @@ TG /read 3 → handler 取会话里的 folder+page 游标 ("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3) ``` -### 5.6 会话状态机(对话 FSM) +### 5.6 Session State Machine (Conversation FSM) -当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。 +There is currently no FSM. The command route is parsed into `Intent` by `parse_intent` in `src/worker.rs`; the AI ​​authorization state is a Boolean expiration time in Redis (see the AI ​​authorization state section of `docs/reference.md` for the key and TTL), not a multi-step confirmation state. -仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:227` 注释:body text never reaches Telegram)。 +Invariants that still hold true: AI digests must first be explicitly authorized by the user, and LLM must not be called without authorization. Once the authorization takes effect, the digest will pull the full text of the email body and send it to LLM (`read_email` requests `TextBody` + `BodyValues` and `fetch_text_body_values(true)`, see `src/domain/jmap/client.rs`), but the body will not be sent back to Telegram** - only the summary will be sent outbound, or it will fall back to the first 300 if it fails. characters (`worker.rs:227` Note: body text never reaches Telegram). -曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。 +The designed 5-state FSM (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`), together with the state transition table and channel neutrality description, can be found in `docs/retired.md`. -### 5.7 邮件搜索(`/search`,`bfe0fd8` 落地) +### 5.7 Email search (`/search`, `bfe0fd8` implemented) -`/search <关键词>` 与中文前缀 `搜索`/`查找`/`检索`(**仅前缀匹配**,避免"帮我搜一下…"被劫持成搜索)触发 `Intent::Search`,经 `JmapService::search_emails(account_id, query, limit)` 走 `email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取 `subject`/`preview` 高亮。 +`/search ` and the Chinese prefix `search`/`find`/`retrieval` (**only prefix matching**, to avoid "Help me search..." being hijacked into a search) triggers `Intent::Search`, through `JmapService::search_emails(account_id, query, limit)` and `email_query`(`Filter::text`) to get the ID + `SearchSnippet/get` Highlight `subject`/`preview`. -关键边界: -- **高亮降级**:`SearchSnippet/get` 不支持(`unknownMethod`)或超时时返回空 snippets 而非报错;渲染降级为纯 ID 列表(`高亮片段暂不可用,以下为匹配的邮件 ID`),不发明片段。 -- **纯文本出站**:`` 高亮标记经 `strip_mark_tags`(大小写不敏感、未闭合标记丢弃)剥离后,再 `unescape_html_entities` 单趟解码;先剥标签再解码实体,防止邮箱正文里字面 ``(经服务器转义为 `<mark>`)被还原成真标签而误删。`subject`/`preview` 分别有 120/160 字符截断上限(`truncate_chars`,与正文截断同工具)。 -- **失败映射**:JMAP 侧失败 → `SearchReply::Retry` → `process_telegram` 返回 `Err(())` → 协调器 503 + `Retry-After` 重试(与 `/reconcile`、其他 JMAP 路径一致),**不 panic、不向用户回错误栈**。 -- **空查询**:`/search`(无关键词)→ 引导提示"请提供搜索关键词,例如:/search 发票",不发请求。无匹配 → `没有找到匹配「…」的邮件。`。 -- **每条命中带 `email_id`**,可直接接 `/summary ` 进入 AI 摘要流程。 -- **正文级高亮在锁定版本做不到**:jmap-client `0.4.2` 的 `SearchSnippet` 只建模 `emailId`/`subject`/`preview`,无 `bodyProperties`/`parts`,按 RFC 8621 §5 的正文级 `body: String[Id]` 不被该 crate 建模且(无 `deny_unknown_fields`)被 serde 静默丢弃。每部分正文高亮需绕过 crate 直发原始 JMAP,当前不实现。 -- **无新环境变量**;缺配置时 `/search` 给友好报错。 +Key boundaries: +- **Highlight downgrade**: `SearchSnippet/get` does not support (`unknownMethod`) or returns empty snippets instead of an error when it times out; rendering is downgraded to a pure ID list (`highlighted snippets are temporarily unavailable, the following are matching email IDs`), and snippets are not created. +- **Plain text outbound**: `` highlighted tags are stripped by `strip_mark_tags` (case-insensitive, unclosed tags are discarded), and then decoded in a single pass by `unescape_html_entities`; tags are stripped first and then entities are decoded to prevent the literal `` (escaped by the server as ``) in the mailbox body from being restored to a real tag and accidentally deleted. `subject`/`preview` have 120/160 character truncation upper limit respectively (`truncate_chars`, the same tool as text truncation). +- **Failed mapping**: JMAP side failed → `SearchReply::Retry` → `process_telegram` returns `Err(())` → coordinator 503 + `Retry-After` retry (consistent with `/reconcile`, other JMAP paths), **no panic, no error stack returned to user**. +- **Empty query**: `/search` (no keywords) → The guidance prompt "Please provide search keywords, for example: /search invoice", no request is sent. No match → `No messages matching "..." were found. `. +- **Each hit contains `email_id`**, you can directly access `/summary ` to enter the AI ​​summary process. +- **Body-level highlighting is not possible in locked versions**: jmap-client `0.4.2`’s `SearchSnippet` only models `emailId`/`subject`/`preview`, without `bodyProperties`/`parts`. According to RFC 8621 §5, the body-level `body: String[Id]` is not modeled by this crate and (none) `deny_unknown_fields`) are silently discarded by serde. The text highlighting of each part needs to bypass the crate and be sent directly to the original JMAP, which is not currently implemented. +- **No new environment variable**; `/search` will give a friendly error when the configuration is missing. --- -## 6. 模块划分 +## 6. Module division -实际工程结构(本文件所有代码锚点均指此结构;历史的目标模块拆分计划见 `docs/retired.md`): +Actual project structure (all code anchors in this document refer to this structure; see `docs/retired.md` for the historical target module splitting plan): ``` message-weave/ ├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide @@ -327,101 +329,101 @@ message-weave/ └── cloudflare-worker/ # 边缘反向代理,JS,不在 Rust 工程中 ``` -测试:**没有集成测试目录**,也没有独立的 util 工具模块。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。 +Testing: **No integrated test directory**, and no stand-alone util tool module. All Rust tests are `#[cfg(test)]` unit tests within each module; the only test on the front end is `web/config.test.mjs`. -### 6.1 模块职责矩阵 +### 6.1 Module Responsibility Matrix -> **依赖现况**:`jmap-client 0.4.2`、`redis 0.27`、`reqwest 0.13` **均已引入**(`ARCH-DEPS-STAGE1` / `ARCH-DEPS-STAGE4`,版本以 `Cargo.toml` 为准)。`teloxide` 经评估**未引入**,Telegram 渠道用 `reqwest` 自研实现(§4,详见 `docs/retired.md`)。下表每一行都是**真实存在的模块**。 +> **Current dependencies**: `jmap-client 0.4.2`, `redis 0.27`, `reqwest 0.13` **have been introduced** (`ARCH-DEPS-STAGE1` / `ARCH-DEPS-STAGE4`, the version is subject to `Cargo.toml`). `teloxide` has been evaluated to be **not introduced**, and the Telegram channel is self-developed using `reqwest` (§4, see `docs/retired.md` for details). Each row in the table below is a real module. -| 模块 | 依赖 | 输出 | 可测性 | 现状 | +| Modules | Dependencies | Output | Testability | Current Status | |---|---|---|---|---| -| `config` | secrecy;ring(`session_digest` SHA-256) | `Config` 结构(7 字段,全部有真实读取方) | 纯函数,易测 | 已实现(Redis 业务配置 serde 反序列化 + `validate_nonblank` fail-closed;启动期 env 由 `main.rs` 直读,无 figment/TOML) | -| `error` | — | `BotError`(§8.1,4 个变体) | 单测 | 已实现 | -| `domain` + `domain::jmap::client` | **jmap-client 0.4.2** | JMAP 只读语义;`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 代码已实现,待真实 Stalwart 端到端验证(`cargo test -- --ignored jmap::`) | -| `state` | redis 0.27 | Redis 读写:配置/开关/会话/TTL 键(`C-REDIS-ONLY-STATE`) | Redis mock | 已实现 | -| `channel` | reqwest 0.13;jmap-client | `TelegramClient`(`send_text` / `send_notification`),邮件与 TG 双渠道(事件→领域 `Notification`→渲染) | mock HTTP | 已实现(`src/channel.rs`,`reqwest` 自研,无第三方 Bot 框架) | -| `worker` | — | `parse_intent` → `Intent`(6 种,含 `Search(query)`:`/search` + `SearchSnippet/get` 高亮渲染),命令路由与 AI 授权判定 | 表驱动纯单测 | 已实现 | -| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 | -| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 | -| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 | -| `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) | +| `config` | secrecy; ring (`session_digest` SHA-256) | `Config` structure (7 fields, all with real readers) | Pure function, easy to test | Implemented (Redis business configuration serde deserialization + `validate_nonblank` fail-closed; startup env is read directly by `main.rs`, no figment/TOML) | +| `error` | — | `BotError` (§8.1, 4 variants) | Single test | Implemented | +| `domain` + `domain::jmap::client` | **jmap-client 0.4.2** | JMAP read-only semantics; `client` = real read-only adapter (`MOD-JMAP-CLIENT`) | mock JMAP response + `#[ignore]` real machine test | Code has been implemented, waiting for real Stalwart end-to-end verification (`cargo test -- --ignored jmap::`) | +| `state` | redis 0.27 | Redis read and write: config/switch/session/TTL key (`C-REDIS-ONLY-STATE`) | Redis mock | implemented | +| `channel` | reqwest 0.13; jmap-client | `TelegramClient` (`send_text` / `send_notification`), dual channels of email and TG (event→field `Notification`→rendering) | mock HTTP | Implemented (`src/channel.rs`, self-developed by `reqwest`, no third-party Bot framework) | +| `worker` | — | `parse_intent` → `Intent` (6 types, including `Search(query)`: `/search` + `SearchSnippet/get` highlight rendering), command routing and AI authorization determination | Table-driven pure single test | Implemented | +| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI compatible endpoint | implemented | +| `notify` | axum; redis | HTTP authentication, global switch, `/push/jmap` enqueue, `Email/changes` reconciliation and cursor submission | webhook verification failure path coverage 403 | The real reconciliation path has been covered; the real Stalwart environment is still required for end-to-end acceptance | +| `web` | axum | `/config` static page + `include_str!` embedding + CSP | Front-end covered by `web/config.test.mjs` | Implemented | +| `debug` | reqwest 0.13; redis 0.27 | Remote diagnostic surface `/debug/*` (`src/debug.rs`): 6 read-only probes `/debug/ping`, `/debug/config`, `/debug/redis`, `/debug/jmap`, `/debug/telegram`, `/debug/worker` + `POST /debug/notify` (sends a test message through the production outbound path, no independent implementation) | Only `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounts the route when both factors are present; the module itself is compiled unconditionally | Implemented, does not enter the Worker whitelist (`SAF-DEBUG-ORIGIN-ONLY`) | -> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`/search`、Telegram 429 退避、多实例重复投递窗口均已实现。`SAF-DEBUG-ALLOWLIST`(`POST /debug/notify` 在空白名单时不拦截任意 `chat_id`)从未计为行为缺口,已在 2026-09-29 从 `docs/opengaps.md` 移出:它的暴露面在生产部署里由 `SAF-DEBUG-GATE` 双因子挂载与 Worker 路由 safelist 兜住,**两道门各自单独都够用**,无需第三层名单,因此不构成缺口。该行为与残余风险(直连后端 origin 可同时绕过这两层)见 `docs/deployment.md` §2.1 与 `docs/design.md` §7.6。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。 +> Note: The "Current Status" column is **module level** (the module has been implemented), which does not mean that the behavior is complete. Behavioral gaps are not included in this list: `/search`, Telegram 429 backoff, and multi-instance re-delivery windows have all been implemented. `SAF-DEBUG-ALLOWLIST` (`POST /debug/notify` does not intercept any `chat_id` when the list is empty) has never been counted as a behavioral gap and was moved from `docs/opengaps.md` on 2026-09-29: its exposure in production deployments is provided by `SAF-DEBUG-GATE` two-factor mount with Worker routing safelist Hold on, **the two doors are sufficient on their own**, and there is no need for a third layer of lists, so there is no gap. This behavior and residual risks (direct connection to the backend origin can bypass both layers) are described in `docs/deployment.md` §2.1 and `docs/design.md` §7.6. The `/search` path of the `worker` module has been implemented with `bfe0fd8`. --- -## 7. 配置与安全 +## 7. Configuration and Security -### 7.1 历史配置读取(已迁移至 Redis) +### 7.1 Historical configuration reading (migrated to Redis) -> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis -> 的首次配置必须通过 `CONFIG_ENCRYPTION_KEY` 认证的一次性 bootstrap/admin 会话完成; -> 不得提供未鉴权写入口。该密钥仅用于常数时间比较,不回显、不记录、不写入业务配置; -> Redis ACL 密码只承担 Redis 连接本身,不再是任何 HTTP 认证凭据。bootstrap 成功后 -> 管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务 token 从 Redis 在启动时装载, -> 密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子替换客户端,后续请求即时 -> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史 -> 兼容说明。 +> **Migration Target (`C-REDIS-ONLY-STATE`)**: Only `REDIS_URL` is retained for the production boot environment. Empty Redis +> First-time configuration must be done through a one-time bootstrap/admin session authenticated by `CONFIG_ENCRYPTION_KEY`; +> Unauthorized write access must not be provided. This key is only used for constant time comparison and does not echo, record, or write business configuration; +> The Redis ACL password is only responsible for the Redis connection itself, not any HTTP authentication credentials. After bootstrap is successful +> The administrator session hash and TTL are saved in Redis and can be restored after restarting; the business token is loaded from Redis at startup. +> Key GET will never be echoed; after bootstrap/administrator PUT is successful, the client will be built and replaced atomically, and subsequent requests will be immediate. +> Use new configuration, keep old instance on failure. This migration replaces the following stage 0 environment variable list. The stage 0 list is only for historical purposes. +> Compatibility instructions. -> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:45` / `:54` / `:63`, -> 另加可选 `DEBUG_TOKEN` 于 `:107`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。 -> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载, -> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。 -> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`), -> 因此**当前代码对下表变量名零读取**;`RUN_MODE` 变量本身也已整体删除(代码中已无该标识符), -> `webhook` 与 `reconcile` 本就共享同一套路由表,`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` §8。 +> **Actual implementation** (`ARCH-CONFIG-ENV`): process startup read-only **2** process-level credentials plus listening port (`src/main.rs:45` / `:54` / `:63`, +> Add optional `DEBUG_TOKEN` in `:107`), directly `std::env::var`, **do not use figment, do not use TOML/configuration file**. +> **Business configuration is no longer read from environment variables**: The fields in the following table are all loaded by the `config` layer using serde deserialization through the Redis business configuration. +> And do fail-closed verification through `validate_business_wire` / `validate_nonblank`. +> Historically existing `Config::from_env()` full env parsing path **removed** (see `docs/retired.md` for registration), +> Therefore **the current code reads zero variable names in the following table**; the `RUN_MODE` variable itself has also been deleted entirely (the identifier is no longer in the code), +> `webhook` and `reconcile` already share the same set of routing tables, `NG-SERVER-MODE` is retained as a design non-target in `docs/charter.md` §8. -> ⚠️ **下表是业务配置字段的完整变量表**(变量名沿用原环境变量命名,便于与部署文档对照,但**代码中已无任何读取方**)。**当前生产部署只需要 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` 两个启动变量**(`src/main.rs`);其余业务字段全部经 Redis 业务配置由 `PUT /api/business-config` 写入(`C-REDIS-ONLY-STATE`)。表中「必填」指**Redis 业务配置**的 fail-closed 约束(`validate_nonblank`,空白即拒绝),不再是环境变量引导路径的必填。 +> ⚠️ **The following table is the complete variable table of business configuration fields** (the variable names are named after the original environment variables to facilitate comparison with the deployment document, but there is no longer any reading method in the code**). **The current production deployment only requires `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` two startup variables** (`src/main.rs`); all other business fields are written by `PUT /api/business-config` through Redis business configuration (`C-REDIS-ONLY-STATE`). "Required" in the table refers to the fail-closed constraint (`validate_nonblank`, blank means rejection) of **Redis business configuration**, which is no longer required for the environment variable boot path. -| 变量 | 必填 | 默认 | 说明 | +| Variable | Required | Default | Description | |---|---|---|---| -| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) | -| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) | -| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) | -| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) | -| `JMAP_SESSION_URL` | 是 | — | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):可填**服务基地址** `https://host[:port]` 或**完整** `…/.well-known/jmap`;代码归一化为 origin/base 后再交 `jmap-client`(其自动追加 `.well-known/jmap`),**不得重复路径**。仅 HTTPS、**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) | -| `JMAP_USERNAME` | 是 | — | Stalwart 账户(邮箱) | -| `JMAP_PASSWORD` | 是 | — | **App Password**(`C-AUTH-APP-BASIC`;`SecretString`) | -| Push verification | 否 | — | Stalwart 动态生成;后端通过 `PushSubscription/set` 自动回写并在 Redis 保存短期验证状态 | -| `REDIS_URL` | 是 | — | 外部 Redis(用户托管 + AOF,`C-REDIS-MANAGED-AOF`;`SecretString`) | -| `RECONCILE_TOKEN` | 是 | — | `/reconcile` 鉴权:`Authorization: Bearer`(`SAF-AUTH-RECONCILE`;`SecretString`)。`/reconcile` 路由始终挂载,故**必填** | -| `ACCOUNT_ID` | 否 | 空 | 单账户(`REQ-SINGLE-ACCOUNT`);留空则取 session 主账户 | -| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) | -| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) | -| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible | -| `LLM_MAX_RETRIES` | 否 | — | 熔断阈值(`REQ-AI-FUSE`)。**非**环境变量:经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1) | -| `LLM_SUMMARY_TARGET_CHARS` | 否(**常量**) | `300` | 摘要目标字数(`REQ-LONG-EMAIL`)。**非**可配置项:`config` 层在两个构造器内硬编码为 300,未进入业务配置 wire | +| `PORT` | No | `8080` | Single listening port (`C-NO-TCP-EXPOSE`) | +| `BOT_TOKEN` | Yes | — | Telegram Bot Token(`SecretString`) | +| `TG_WEBHOOK_SECRET` | Yes | — | `/webhook/tg` Authentication: Request header `X-Telegram-Bot-Api-Secret-Token` (`SAF-AUTH-TG-WEBHOOK`; `SecretString`) | +| `CHAT_ALLOWLIST` | Yes | — | Comma separated integer chat ids; **Whitelist Hard Constraint** (`SAF-CHAT-ALLOWLIST`) | +| `JMAP_SESSION_URL` | Yes | — | Stalwart JMAP session URL (`REQ-JMAP-SESSION-URL`): You can fill in the **service base address** `https://host[:port]` or **complete** `…/.well-known/jmap`; the code is normalized to origin/base and then submitted to `jmap-client` (it is automatically appended `.well-known/jmap`), **paths must not be repeated**. HTTPS only, **URL embedded credentials prohibited** (`SAF-JMAP-URL`) | +| `JMAP_USERNAME` | Yes | — | Stalwart Account (Email) | +| `JMAP_PASSWORD` | Yes | — | **App Password**(`C-AUTH-APP-BASIC`; `SecretString`) | +| Push verification | No | — | Dynamically generated by Stalwart; the backend automatically writes back through `PushSubscription/set` and saves the short-term verification status in Redis | +| `REDIS_URL` | Yes | — | External Redis (User-Managed + AOF, `C-REDIS-MANAGED-AOF`; `SecretString`) | +| `RECONCILE_TOKEN` | Yes | — | `/reconcile` Authentication: `Authorization: Bearer` (`SAF-AUTH-RECONCILE`; `SecretString`). `/reconcile` route is always mounted, so **required** | +| `ACCOUNT_ID` | No | Empty | Single account (`REQ-SINGLE-ACCOUNT`); if left blank, the session main account will be used | +| `LLM_ENABLED` | No | `false` | Do not verify `LLM_*` on shutdown (`REQ-LLM-OPENAI-COMPAT`) | +| `LLM_ALLOW_NET` | No | `false` | AI outgoing network switch (`REQ-AI-EXTERNAL-CONSENT`) | +| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | Required when `LLM_ENABLED=true` | — | OpenAI-compatible | +| `LLM_MAX_RETRIES` | No | — | Fuse threshold (`REQ-AI-FUSE`). **Non** environment variables: managed by Redis running parameters (`OutboundConfig.max_retries`, fall back to default, see `docs/reference.md` §6.1) | +| `LLM_SUMMARY_TARGET_CHARS` | No (**constant**) | `300` | Summary target word count (`REQ-LONG-EMAIL`). **Non** configurable items: The `config` layer is hard-coded to 300 in the two constructors and does not enter the business configuration wire | -> 当前 `config.rs` 从 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`);进程级启动变量由 `src/main.rs` 直读,不经过 `config` 层。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 -> **入口鉴权密钥(`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`)在业务配置完成后必须有效**(fail-closed,`SAF-AUTH-*`)。JMAP Push 的验证码由 Stalwart 在订阅创建后动态生成,不属于业务配置。启动引导变量缺失时服务进入配置引导模式并保持 SPA 可访问;不会因为缺少启动变量而伪造业务成功,也不会绕过入口鉴权。 -> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。**重定向白名单**:`jmap-client` 默认**拒绝一切重定向**,而 Stalwart 会把 `/.well-known/jmap` 307 跳到 `/jmap/session`,因此连接时把**配置里的 origin host 自身**加入信任名单,服务端改写出的其它 host 仍被拒绝(`follow_redirects([trusted_redirect_host(base)])`)。已知上游隐患:Stalwart 的 307 `Location` 会把 `user:pass` 写进 URL,且客户端把该 URL 带凭据回传——故传输错误串**不做额外日志**。(状态:D-G1-1 **代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证**。) +> Currently `config.rs` provides the above fields (including `TelegramConfig` / `JmapConfig` / `LlmConfig`) from Redis business configuration; process-level startup variables are read directly by `src/main.rs` without going through the `config` layer. The introduction of TOML/figment in the future will require separate decisions; the current documentation does not assume that the configuration file exists. +> **The entrance authentication key (`TG_WEBHOOK_SECRET`/`RECONCILE_TOKEN`) must be valid after the business configuration is completed** (fail-closed, `SAF-AUTH-*`). The verification code of JMAP Push is dynamically generated by Stalwart after the subscription is created and does not belong to the business configuration. When the startup boot variables are missing, the service enters configuration boot mode and remains accessible to SPA; business success will not be faked due to lack of startup variables, nor will entrance authentication be bypassed. +> **JMAP session URL normalization (`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**: `JMAP_SESSION_URL` accepts both the **service base address** (`https://mail.example.com`) and the **full session URL** (`https://mail.example.com/.well-known/jmap`). The code is unified and normalized to origin/base before calling `jmap_client::Client::connect` **——`jmap-client` will automatically append `/.well-known/jmap`, so **there will be no duplicate paths** (such as `…/.well-known/jmap/.well-known/jmap`). Constraints: **HTTPS only** (http denied); **Disallow URL embedded username/password** (credentials only via `JMAP_USERNAME`/`JMAP_PASSWORD`, `C-AUTH-APP-BASIC`); deny dangerous queries. **Redirect whitelist**: `jmap-client` **denies all redirections** by default, and Stalwart will jump `/.well-known/jmap` 307 to `/jmap/session`, so the origin host itself in the **configuration** is added to the trust list when connecting, and other hosts rewritten by the server are still rejected (`follow_redirects([trusted_redirect_host(base)])`). Known upstream risks: Stalwart's 307 `Location` will write `user:pass` into the URL, and the client will return the URL with credentials - so the transmission error string will not be logged additionally. (Status: D-G1-1 **Code implemented and verified with `cargo test -- --ignored jmap::` with a real Stalwart instance**.) -### 7.2 密钥管理 -- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置不进仓库,`ARCH-CONFIG-ENV`)。 -- 启动期只读 2 个进程级变量(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`,由 `src/main.rs` 直接 `std::env::var`;缺任一即降级到只读 setup 模式);业务配置从 Redis 经 `config` 层反序列化(**无** `${VAR}` 插值/figment/配置文件,`ARCH-CONFIG-ENV`)。 -- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。 -- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。 -- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 +### 7.2 Key Management +- **It is prohibited** to write token/password into the warehouse or any configuration file (this project configuration does not enter the warehouse, `ARCH-CONFIG-ENV`). +- Only 2 process-level variables are read during startup (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`, directly `std::env::var` from `src/main.rs`; if any one is missing, it will be downgraded to read-only setup mode); the business configuration is deserialized from Redis through the `config` layer (**None** `${VAR}` interpolation/figment/config files, `ARCH-CONFIG-ENV`). +- Use `secrecy::SecretString` to wrap it during runtime, and `Debug` to implement `***`. +- Log filtering: `tracing` field layer shielding `Authorization`/`password`/`token`. +- For the runtime secret injection method (env / `*_FILE` / orchestrator secret), see deployment.md (`C-NO-SECRET-IN-IMAGE`). -### 7.3 访问控制 -- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**—— - - `/reconcile`:请求头 `Authorization: Bearer `; - - `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`; - - `/push/jmap`:按 `pushSubscriptionId` 校验 Redis 短期状态;首次 verification 由 JMAP `PushSubscription/set` 回写成功后才放行 StateChange。 - 比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。业务配置完成后,Webhook、Push、Reconcile 和 Push 注册接口的凭证必须有效;启动引导变量缺失时进入配置引导模式,不绕过鉴权(§7.1)。 -- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。 - - `/healthz` = liveness(进程存活),语义长期稳定。 - - `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。 -- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。 -- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 -- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 +### 7.3 Access control +- **Entry authentication (hard constraints `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**: The three write paths must be authenticated first, **fail-closed**—— + - `/reconcile`: Request header `Authorization: Bearer `; + - `/webhook/tg`: Request header `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`; + - `/push/jmap`: Verify Redis short-term status according to `pushSubscriptionId`; StateChange is released only after the first verification is successfully written back by JMAP `PushSubscription/set`. + The comparison uses the **constant time** algorithm (`subtle`, anti-timing side channel); verification failure will always be `401` and **will not produce any side effects/status changes** before the authentication is passed. After the business configuration is completed, the credentials for Webhook, Push, Reconcile and Push registration interfaces must be valid; when the startup boot variable is missing, the configuration boot mode will be entered and authentication will not be bypassed (§7.1). +- **Health probe (`SAF-PROBE-PUBLIC`)**: `/healthz` (`ARCH-HEALTHZ`) and `/ready` are **public probes** - no authentication, only returns health status, **does not contain any sensitive information** (does not echo configuration/key/internal error details). + - `/healthz` = liveness (process survival), long-term stable semantics. + - `/ready` does end-to-end probing: configuration integrity + Redis reachability + outbound read-only probing (`GET {jmap_origin}/.well-known/jmap`, Basic authentication with configuration; `GET https://api.telegram.org/bot/getMe`; each `PROBE_TIMEOUT` = 3000ms, **Parallel** (`tokio::join!`), worst-case scenario 3s); all four pass `200` and readiness report (`{"status":"ready","configured":...,"jmap":...,"telegram":...}`, where `jmap`/`telegram` is the real probe result), any one fails `503` (standard error envelope `{"error":"service_unavailable","request_id":}` + `Retry-After: 30`). The probe is read-only, read-only configuration status, and will not trigger business side effects such as email synchronization, nor will it echo token or third-party response content; `refresh_business_config` only reads Redis, and has no state to write. Therefore `/ready` requires the outbound egress to the JMAP host and `api.telegram.org:443` to be reachable (if the egress requires a proxy, `/ready` is not available, see deployment.md). +- **chat whitelist (hard constraint `SAF-CHAT-ALLOWLIST`)**: `CHAT_ALLOWLIST` is a **required** configuration; any inbound event (action triggered by TG command/callback) must verify `chat.id ∈ CHAT_ALLOWLIST` before **making any JMAP call, AI call or status change**. If it is not in the whitelist, **directly reject and terminate** (prevent token can be called by anyone after being leaked). Phase 0 has completed the `CHAT_ALLOWLIST` parsing skeleton; the forced rejection logic has been implemented with Telegram channel access (`telegram_webhook` of `src/notify.rs` verifies the whitelist before any JMAP/AI/status operation, and terminates if rejected). +- **Command Minimization**: Only necessary commands are exposed; writing operations such as sending emails must be confirmed twice (sending emails is not currently implemented, see §10.3). +- **Rate**: No local token bucket is built on the outbound side; Telegram's outbound sending is retried according to the Redis operating parameter `max_retries` (see `docs/reference.md` §6.1 for default values ​​and hard upper limits); currently only Redis current limiting is done for Push verification code writing (`ratelimit:push-verify:*`). 429 under the 30 msg/s limit of the Telegram server **Automatic backoff in `parameters.retry_after` seconds** (`channel.rs`, `f4cae00`): `retry_after_ms` is truncated to the 60s budget limit after parsing, and the exponential backoff is used when this field is missing or non-numeric `backoff_delay_ms` (starting from 250ms, capped 4s), the overall retry budget is 60s. There is still no local token bucket current limit - if the budget is exceeded, a failure will be returned directly and the upstream will try again. -### 7.3.1 配置管理 API -- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 -- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`+`),SPA 页脚据此确认部署是否真正落地。 -- `POST /api/admin/session` 接受 `Authorization: Bearer `,返回 `{ "session": "", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 -- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 -- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: +### 7.3.1 Configuration Management API +- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`). +- `GET /api/status` publicly returns `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`, which only lists the missing environment variable names. When `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, SPA only displays the configuration boot status and missing variables; the management session authorization area is displayed only after the service status is confirmed to be ready=true. `version` is the `BUILD_VERSION` (`+`) baked by `build.rs` during compilation. The SPA footer uses this to confirm whether the deployment is actually implemented. +- `POST /api/admin/session` accepts `Authorization: Bearer ` and returns `{ "session": "", "expires_in": 900 }`; the admin session only saves the digest in Redis and expires after 900 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully. +- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is. +- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters: ```json { "jmap_timeout_ms": 15000, @@ -430,43 +432,43 @@ message-weave/ "max_retries": 3 } ``` -- 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 -- `GET /api/business-config` 回读已保存的配置供管理 SPA 预填表单:返回 `{ "configured", "revision", "values", "secrets_present" }`,其中 `values` 是 10 个非密钥字段的值,`secrets_present` 是 6 个密钥的存在性布尔(`bot_token`、`jmap_password`、`telegram_webhook_secret`、`reconcile_token`、`worker_token`、`llm_api_key`)——**从不返回密钥值本身**(`SAF-NO-SECRET-ECHO`)。尚未保存过时返回 `200` + `configured: false`、`revision: 0`、空 `values` 与全 false 的存在性布尔,SPA 因此只有一条代码路径,无需区分首次配置与修改配置。 -- `PUT /api/business-config` 接受 `BusinessConfigPatch`(完整或部分),**只替换提交体中出现的字段,其余沿用已存值**(`BusinessConfigPatch::apply`);Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**,且校验对象是**合并后的**配置而非提交体本身:补丁从不被单独校验,已存的完整 Wire 也从不被丢弃。`validate_business_wire` 先跑,拒绝时返回真实的 `422`;合并结果合法则配置**一定**被持久化(持久化的是合并后的 Wire,不是请求体),之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头;请求体可带 `revision`(即 `GET` 返回的那个 `u64`)作为控制字段——它不参与 `apply`、不进 Wire、不作为已存设置,只用于判断本次提交是否基于最新版本,`409` 语义见下条。响应从不回显配置或密钥。密钥是"提交才替换":**省略**的密钥沿用已存值,因此由 `GET` 回填的表单只需提交要改的字段;但后端不区分"未提交"与"提交空字符串",显式提交 `""` 会真的清空该密钥 —— "留空即保留"是 SPA 在提交前丢弃空白密钥字段实现的客户端契约,不是服务端保证。`chat_allowlist` 必须为非空列表,空数组会被 `validate_business_wire` 拒绝(422)。增量语义只在**已有配置**之后成立:没有任何已存配置时没有可回退的值,补丁必须自带全部必填字段,否则返回 `422`(从零创建的入口是 `POST /api/bootstrap`)。 -- `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。与 PUT 不同,它的提交体必须是**完整的** `BusinessConfigWire`(不做补丁合并),所以 SPA 在预校验前要先把 `GET` 回读的值合并进本地表单。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。 -- `POST /api/bootstrap` 也接受**完整的** `BusinessConfigWire`(不做补丁合并),并共用上面「持久化与报错分离」的语义:校验失败是 `422`、持久化后构建失败仍是 `200` + warnings。因为不合并,它是唯一能**从零创建**业务配置的补丁路径(PUT 在已存配置缺失且补丁不全时返回 `422`,见上条)。 -- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(补丁无法解析、已存配置不可解密、合并结果校验失败、首次保存时补丁不完整)、`409`(提交的 `revision` 已过期)、`500`(合并结果序列化失败)与 `503`(Redis 不可达、写入失败或 reload 提交失败);业务 GET 使用 `401`、`400`、`422`(已存配置不可解密)与 `503`(Redis 不可达)——注意 `Invalid` 是 422 不是 503,`Unreachable` 才是 503。**业务 PUT 有 `409`**:`revision` 是**请求体里的控制字段**(不是 header)——SPA 读配置时记下 `revision`,之后的每次增量提交都带回同一个值;已存 revision 与请求 revision 不等时返回 `409 conflict`,既不写入也不自增 revision,SPA 收到 `409` 会重新读回已存值再让操作者改,所以旧页面不会静默覆盖新的保存。请求体不带 `revision` 时保持 last-write-wins,兼容从不读 revision 的调用方。该比较是**先比较后写入**、非原子:两个请求若在同一瞬间竞争,两个都会通过、后到的覆盖先到的;没有 Lua 脚本时乐观锁的代价就是这个残留窗口,而补丁只带操作者实际改过的字段,窗口影响有限。`409 conflict` 的其余出现处是三把锁的竞争(`reconcile` 与 `register_push` / `register_worker_token` 的 `acquire_lock` 返回 false),与业务配置写入无关。依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 +- The three timeout units are all milliseconds; the default value, value range and hard upper limit of `max_retries` are only authoritative with `docs/reference.md` §6.1 (the table below and the example response body above are only for illustration, and the values ​​​​are not repeated). `max_retries` represents the maximum number of retries beyond the first request. When there is no configuration in Redis, GET returns the default value object; PUT successfully returns the same object after saving and persists to Redis (`C-REDIS-ONLY-STATE`). +- `GET /api/business-config` reads back saved configuration for management SPA pre-populated form: returns `{ "configured", "revision", "values", "secrets_present" }`, where `values` is the value of 10 non-key fields and `secrets_present` is 6 Boolean existence of a key (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`, `worker_token`, `llm_api_key`) - **Never return the key value itself** (`SAF-NO-SECRET-ECHO`). Not yet saved, staleness returns `200` + `configured: false`, `revision: 0`, empty `values` and an existence boolean of all false. SPA therefore has only one code path and does not need to distinguish between first-time configuration and modified configuration. +- `PUT /api/business-config` accepts `BusinessConfigPatch` (complete or partial), **only replaces the fields that appear in the submission body, and the rest inherits the stored values** (`BusinessConfigPatch::apply`); Push verification does not belong to Wire, is dynamically generated by Stalwart and written back by the backend. Verification is the only write gate, and the verification object is the merged configuration rather than the commit itself: patches are never verified individually, and existing complete wires are never discarded. `validate_business_wire` runs first, and returns the real `422` when rejected; if the merge result is legal, the configuration **must** be persisted (the merged Wire is persisted, not the request body), and then the client is built and the worker is switched to run. Therefore, when the dependency is not connected, `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }` is returned instead of `503` - the configuration has been saved and the fault is reported truthfully instead of being silently swallowed by an unreachable JMAP. `runtime_applied` is only `true` when the reload submission is successful; in both cases, the `x-business-config-revision` header is returned; the request body can carry `revision` (that is, the `u64` returned by `GET`) as a control field - it does not participate in `apply`, does not enter Wire, and is not used as a saved setting. It is only used to determine whether this submission is based on the latest version. See the next article for the semantics of `409`. The response never echoes configuration or keys. The key is "replaced only after submission": the **omitted** key inherits the existing value, so the form backfilled by `GET` only needs to submit the fields to be changed; but the backend does not distinguish between "unsubmitted" and "submitted empty string", and explicitly submitting `""` will actually clear the key - "leave blank and keep" is a client contract implemented by SPA by discarding blank key fields before submission, not a server-side guarantee. `chat_allowlist` must be a non-empty list, empty arrays will be rejected by `validate_business_wire` (422). Incremental semantics only hold after **existing configuration**: if there is no existing configuration, there is no fallback value, and the patch must come with all required fields, otherwise `422` will be returned (the entry created from scratch is `POST /api/bootstrap`). +- `POST /api/business-config/preflight` performs exactly the same validation and client build on the submission body, returning component-by-component conclusions, **without writing, without touching the running worker**: `{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`. Different from PUT, its submission body must be **complete** `BusinessConfigWire` (no patch merging), so SPA must first merge the value read back by `GET` into the local form before pre-verification. `components` is `null` when the verification has failed (rejected Wire does not need to probe the client again); when `llm_enabled`/`llm_allow_net` is false, `llm` is `null` instead of false `ok`. Authentication is consistent with the management session gate (`config_authorized`) and is deliberately not hung after `DEBUG_TOKEN`. +- `POST /api/bootstrap` also accepts **complete** `BusinessConfigWire` (without patch merging), and shares the above semantics of "separation of persistence and error reporting": verification failure is `422`, and build failure after persistence is still `200` + warnings. Because it does not merge, it is the only patch path that can create business configurations from scratch (PUT returns `422` when the existing configuration is missing and the patch is incomplete, see the previous article). +- The running parameter errors of `PUT /api/config` use `401` (Unauthorized), `400` (JSON Invalid), `422` (Scope Error), `503` (Redis Unavailable); when GET read fails, `503` also indicates that Redis is unavailable. Business PUT uses `401` (unauthorized), `400` (JSON is invalid), `422` (the patch cannot be parsed, the saved configuration cannot be decrypted, the merge result verification fails, and the patch is incomplete when saving for the first time), `409` (the submitted `revision` has expired), `500` (merge result serialization failed) and `503` (Redis is unreachable, writing failed or reload submission failed); business GET uses `401`, `400`, `422` (the saved configuration cannot be decrypted) and `503` (Redis is unreachable) - note that `Invalid` is 422, not 503, and `Unreachable` is 503. **Business PUT has `409`**: `revision` is a **control field** in the request body** (not a header) - SPA records `revision` when reading the configuration, and each subsequent incremental submission will bring back the same value; the saved revision and the requested revision are not equal and return `409 conflict`, neither writing nor incrementing the revision, SPA receives `409` The saved value will be read back again and allowed to be changed by the operator, so the old page will not silently overwrite the new save. The request body without `revision` maintains last-write-wins, which is compatible with callers that never read revisions. This comparison is **compare first and then write**, and is non-atomic: if two requests compete at the same moment, both will pass, and the later one will overwrite the first one. The cost of optimistic locking without Lua scripts is this residual window, and the patch only takes the fields actually changed by the operator, so the window impact is limited. The remaining occurrences of `409 conflict` are the competition of three locks (`reconcile` and `acquire_lock` of `register_push` / `register_worker_token` return false), which have nothing to do with business configuration writing. Dependent client build failure does not return an error status code, but `200` + warnings (see above). The remaining errors do not echo sensitive data. The management page displays a `503` status when Redis/session initialization is not completed; running parameters are disabled from being saved before re-reading successfully (`C-REDIS-ONLY-STATE`). When LLM is enabled, the business configuration requires an HTTPS Base URL, a non-empty API key, and a model name. -### 7.4 TLS(代码/依赖行为) -- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 -- 证书校验**默认强制**,仅测试可关。 -- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。 +### 7.4 TLS (code/dependent behavior) +- The TLS provider is determined by the dependent feature: **jmap-client 0.4.2 contains `aws_lc_rs` by default (and introduces `rustls`)**, and `default-features = true` is not "rustls default". **The actual `default-features = false, features = ["async","rustls"]`** of this project (`aws_lc_rs`/`websockets` is not enabled), and will ultimately be subject to `Cargo.toml`. +- Certificate verification is **mandatory by default** and can be turned off only for testing. +- For operation and maintenance details such as the system CA trust store in the image, internal CA injection, inbound webhook TLS termination, etc., see deployment.md. -### 7.5 生产红线:无数据库 / 无本地写入 / 标准输出日志(deployment.md §0/§8.2) +### 7.5 Production red line: no database / no local writing / standard output log (deployment.md §0/§8.2) -本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态: +This section summarizes the absolute red lines across code and deployment that you must not introduce any "seemingly convenient" local state into your code: -| 红线 ID | 含义 | 违反示例(禁止) | +| Redline ID | Meaning | Violation Example (Prohibited) | |---|---|---| -| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 | -| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写日志到本地文件、写临时目录、把会话游标落成本地状态文件 | -| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` | -| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 | -| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 | +| `C-NO-DB` | Production does not use any database; Redis is the only production state storage | Add SQLite deduplication table, add Postgres session | +| `C-NO-LOCAL-WRITE` | Disable writing of local files/directories; do not mount local volumes | Write logs to local files, write temporary directories, and place session cursors into local status files | +| `C-LOG-STDOUT-ONLY` | The log only writes stdout/stderr; the platform is responsible for collection | Introducing `tracing-appender`, `rolling-file`, `FileAppender` | +| `SAF-LOG-PURITY` | Log and Redis writing content is limited to structured events, counts, timestamps, desensitized summaries | Log printing JMAP text, AI prompt/completion, key original text, attachment content | +| `C-NO-STATEFUL-RECOVERY` | Production recovery does not rely on in-process state | Use `static Mutex` to store dedup and use memory LRU to store sinceState as the only recovery source | -**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 +**Verification**: No local path constants / `std::fs` non-test calls / file log backends appear in the static inspection code; during runtime, the container `/proc/mounts` and `docker inspect` are used to confirm that there is no local volume mounting; see deployment.md §8.2 for the deployment checklist. -### 7.6 远程联调面为何默认绝对关闭 +### 7.6 Why is the remote joint debugging interface absolutely closed by default? -该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。 +The only reason for this surface to exist is to shorten the production troubleshooting path: when you cannot log in to the container and can only rely on external requests to observe the system, someone needs to be able to detect the JMAP/Telegram connectivity and current business configuration without releasing the version. The price is that its entry softness must be higher than the three write paths - in addition to read-only detection, it also retains a **real outbound send**, and reuses the business whitelist instead of an independent whitelist. Therefore, the design choice is "closed by default" instead of "enabled by default and blocked by the gateway": the process will only mount this set of routes (`SAF-DEBUG-GATE`) when "`DEBUG_ENABLED` is true or the command line has `--debug`"** and **a non-empty `DEBUG_TOKEN` is set; when the route is missing, the route does not exist at all in the router, and the request falls to axum general `404`, not "Exists but 401" - the latter would leak the route's existence. There is no third state: there is no "open without configuration" fallback, and there is no configuration item to set it to be enabled by default. `DEBUG_ENABLED` uses environment variables instead of startup commands so that the switch can be changed once from the platform console without releasing the version - the startup command remains static. Add another layer of location constraints: it is not in the gateway's safe routing whitelist. Even if the backend is opened incorrectly, the platform entrance will be rejected by fail-closed. The only reachable path is to directly connect to the backend origin. These four things—two-factor mounting, 404 instead of 401, no default enabled fallback, and unreachable gateway—together form the architectural decision of “exposure is zero by default.” See deployment.md §2.1 for the enablement method, per-endpoint status code, and deployment confirmation checklist. --- -## 8. 错误处理与可观测性 +## 8. Error handling and observability -### 8.1 统一错误枚举 +### 8.1 Unified error enumeration -当前实现(`src/error.rs` 全文,4 个变体): +Current implementation (full text of `src/error.rs`, 4 variants): ```rust use thiserror::Error; @@ -484,317 +486,317 @@ pub enum BotError { } ``` -设计目标形态曾包含 `Jmap(#[from] jmap_client::Error)`、`Telegram(#[from] teloxide::errors::RequestError)`、`Storage(#[from] redis::RedisError)`、`RateLimited`、`Unauthorized(i64)`、`Llm(LlmErr)` 六个额外变体;`Telegram` 随 `teloxide` 未采用而废弃,其余至今未落地,完整清单见 `docs/retired.md`。 +The design target form once included six additional variants: `Jmap(#[from] jmap_client::Error)`, `Telegram(#[from] teloxide::errors::RequestError)`, `Storage(#[from] redis::RedisError)`, `RateLimited`, `Unauthorized(i64)`, `Llm(LlmErr)`; `Telegram` comes with `teloxide` They were abandoned without adoption, and the rest have not yet been implemented. For a complete list, see `docs/retired.md`. -### 8.2 错误 → 用户消息 映射 -| 底层错误 | Bot 行为 | +### 8.2 Error → User Message Mapping +| Low-level errors | Bot behavior | |---|---| -| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" | -| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 | -| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" | -| `Set(creation/update/destroy)` | 回复具体记录级失败原因 | -| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) | -| `Llm(*)` | 见 §12.7(熔断/回退/徽标) | +| `Transport` / `ServerUnavailable` / `RateLimit` | Exponential backoff retry (`util::retry`), exceeding the threshold gives the user "temporarily unavailable, try again later" | +| `Forbidden` / `Unauthorized chat` | Silent rejection; log alert | +| `Method(InvalidArguments)` | Reply to "Wrong parameter + correct usage" | +| `Set(creation/update/destroy)` | Reply to the specific record-level failure reason | +| `StateMismatch` | Reset sinceState → Fill in full once (anti-lost/anti-repair) | +| `Llm(*)` | See §12.7 (Break/Fallback/Logo) | -### 8.3 可观测性 -- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。 -- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。 -- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。 -- 优雅退出:**当前未实现**——`src/` 里没有信号处理(无 `tokio::signal` / `ctrl_c`),也没有 Streams pending 条目的 flush 逻辑;容器停机即终止进程。Redis 侧 `C-NO-STATEFUL-RECOVERY` 保证重启后从 Redis 重建,不依赖进程内状态。 -- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。 +### 8.3 Observability +- `tracing` (direct dependency) + `tracing-subscriber` (fmt + EnvFilter). +- `tracing` (direct dependency) + `tracing-subscriber` (fmt + EnvFilter). **Currently only recording startup events**: `src/main.rs` 5 `info!`/`warn!` in total (missing startup variables, downgrade to setup mode, startup banner, debug endpoint enabled, JMAP service unavailable, downgrade × 2). Request-level events (Push callbacks, Reconcile pulls, channel pushes, LLM elapsed times) **not implemented yet** - there are no tracing calls in `src/` except `main.rs`, and no named spans (no `#[instrument]` / `span!`). +- Metrics (optional `metrics` crate): Number of Push callback arrivals, deduplication hit rate, Redis Streams backlog depth (pending), number of DLQ items, number of reconciliation items, JMAP request delay, push failure rate. **Currently not connected to any metrics backend** (`metrics` is not in `Cargo.toml`). +- Graceful exit: **Currently not implemented** - There is no signal processing in `src/` (no `tokio::signal` / `ctrl_c`), and there is no flush logic for Streams pending entries; the process is terminated when the container stops. Redis side `C-NO-STATEFUL-RECOVERY` ensures reconstruction from Redis after restart and does not rely on in-process state. +- **Reliability goals and strategies** (Streams ACK/retry, idempotent deduplication, Push retry, reconciliation recovery, indicators/alarms, **≥99.9% notification availability and boundaries**) see deployment.md §6.4/§6.5 (`NFR-NOTIFY-SLA`). --- -## 9. 测试策略 +## 9. Test strategy -### 9.1 层次 +### 9.1 Levels -| 层 | 位置 | 内容 | +| Layers | Location | Content | |---|---|---| -| 单元 | `src/*.rs` 内 `#[cfg(test)] mod tests` | `config` 解析、`worker` 命令路由、`ai` 的 HTTPS 校验、`state` Redis 封装、`channel` 出站、`notify` 校验与去重、`main` 启动分支 | -| 契约 | `src/domain/jmap.rs` | `JmapBackend` trait + `MockBackend`:用预置数据验证领域动词,不联网 | -| 集成 | `src/channel.rs` 测试模块 | 用进程内 `tokio::net::TcpListener` 起 mock HTTP 服务,验证出站请求的路径、状态码与重试 | -| 真实(手动) | `src/domain/jmap/client.rs` | 标 `#[ignore]`,需显式配置 JMAP 测试服务器后 `cargo test -- --ignored` 才跑 | +| Unit | `#[cfg(test)] mod tests` in `src/*.rs` | `config` parsing, `worker` command routing, `ai` HTTPS verification, `state` Redis encapsulation, `channel` outbound, `notify` verification and deduplication, `main` startup branch | +| Contract | `src/domain/jmap.rs` | `JmapBackend` trait + `MockBackend`: validate domain verbs with preset data, not connected to the Internet | +| Integration | `src/channel.rs` test module | Use in-process `tokio::net::TcpListener` to start mock HTTP service, verify the path, status code and retry of outbound requests | +| Real (manual) | `src/domain/jmap/client.rs` | Marked with `#[ignore]`, you need to explicitly configure the JMAP test server before running `cargo test -- --ignored` | -**没有集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。 +**No integration test directory**; the only test on the front end is `web/config.test.mjs` (Node native assertion). -### 9.2 mock 策略 -- `JmapService` 持有 trait `JmapBackend`(`list_emails` / `read_email` / `send_email` / `changes` 等),生产实现包装 `jmap_client::Client`(`src/domain/jmap/client.rs`),测试用 `MockBackend`(`src/domain/jmap.rs`)。 -- Telegram 出站不引入任何框架 mock:`src/channel.rs` 起一个进程内 `TcpListener`,用 `AtomicUsize` 记录请求次数,验证 URL、状态码与超时/重试行为。 -- `src/ai.rs` 通过构造 `LlmClient` 的配置断言 HTTPS 强制(`llm_requires_https`);摘要逻辑在 mock OpenAI 兼容端点上验证。 -- 渠道与领域解耦靠类型约束(`domain.rs` 的公开接口不出现渠道 SDK 类型),不依赖 mock channel。 +### 9.2 mock strategy +- `JmapService` holds the trait `JmapBackend` (`list_emails` / `read_email` / `send_email` / `changes` etc.), the production implementation wrapper `jmap_client::Client` (`src/domain/jmap/client.rs`), and the test `MockBackend` (`src/domain/jmap.rs`). +- Telegram outbound does not introduce any framework mock: `src/channel.rs` starts an in-process `TcpListener`, uses `AtomicUsize` to record the number of requests, and verifies the URL, status code and timeout/retry behavior. +- `src/ai.rs` asserts HTTPS enforcement (`llm_requires_https`) by constructing the configuration of `LlmClient`; summary logic validates on mock OpenAI compatible endpoint. +- The decoupling of channels and domains relies on type constraints (channel SDK types do not appear in the public interface of `domain.rs`) and does not rely on mock channels. -### 9.3 关键不变量测试 -- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。 -- 通知去重:同一 `email_id` 不重复推送。 -- 正文转义:含 `