docs: add bilingual guides and callback setup in SPA
This commit is contained in:
parent
98d62e490c
commit
7a27f7ff00
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 为准。
|
||||
|
|
|
|||
|
|
@ -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**。
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
446
docs/charter.md
446
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":<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":<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 |
|
||||
|
|
|
|||
|
|
@ -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":<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 才算通过 | 门禁 |
|
||||
1095
docs/deployment.md
1095
docs/deployment.md
File diff suppressed because it is too large
Load Diff
|
|
@ -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://<PUBLIC_URL>' 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
|
||||
仍然需要。它不添加数据库、长期连接或第二层
|
||||
商业认证。
|
||||
994
docs/design.md
994
docs/design.md
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,802 @@
|
|||
# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计
|
||||
|
||||
> [English version / 英文版 → design.md](design.md)
|
||||
|
||||
> 状态:**设计与实现均已落地**(代码见仓库 `src/`、`web/`、`cloudflare-worker/`;本文同时保留设计决策与选型评估的原始记录,阶段 1/2/4 的表述属历史计划,当前进度见 §10.5)
|
||||
> 作者:Cowork(team: MessageWeave)
|
||||
> 日期:2026-09-21
|
||||
> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审
|
||||
>
|
||||
> **相关文档**(职责分离,避免重复堆砌):
|
||||
> - [docs/charter.md](charter.zh-CN.md) — 项目章程:项目目标、技术选型、安全不变量、实现阶段、禁止事项、测试验收与稳定 ID 注册表
|
||||
> - [AGENTS.md](../AGENTS.md) — 通用、语言无关的代码编写与环境构建规范(不含项目专属内容)
|
||||
> - [docs/deployment.md](deployment.zh-CN.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与仍需确认项
|
||||
>
|
||||
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维与 Docker/通用容器平台细节已移至 `deployment.md`;项目约束与安全不变量在 `docs/charter.md`;通用、语言无关的代码编写与环境构建规范在 `../AGENTS.md`。
|
||||
|
||||
---
|
||||
|
||||
## 0. Document navigation
|
||||
|
||||
1. [任务与范围](#1-task-and-scope)
|
||||
2. [jmap-client 能力分析](#2-jmap-client-capability-analysis)
|
||||
3. [认证与邮箱操作适配](#3-authentication-and-email-operation-adaptation)
|
||||
4. [Telegram 渠道实现选型](#4-telegram-channel-implementation-selection)
|
||||
5. [整体架构与数据流](#5-overall-architecture-and-data-flow)
|
||||
6. [模块划分](#6-module-division)
|
||||
7. [配置与安全](#7-configuration-and-security)
|
||||
8. [错误处理与可观测性](#8-error-handling-and-observability)
|
||||
9. [测试策略](#9-test-strategy)
|
||||
10. [分阶段实施计划](#10-phased-implementation-plan)
|
||||
11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-archive-of-historical-issues-and-decisions-productarchitecture-category-all-have-conclusions)
|
||||
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-auxiliary-capabilities-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`**。
|
||||
|
||||
---
|
||||
|
||||
## 1. Task and Scope
|
||||
|
||||
### 1.1 Goals
|
||||
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**:
|
||||
|
||||
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
|
||||
- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。)
|
||||
- 单用户或多账户部署(默认面向单账户自托管场景)。
|
||||
|
||||
### 1.2 Scope (this stage)
|
||||
- ✅ 调研 + 方案设计(本文档)
|
||||
- ❌ 不创建/修改项目代码、不初始化 cargo 工程
|
||||
- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项
|
||||
|
||||
### 1.3 Research basis
|
||||
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
|
||||
- 项目目录:`/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`)
|
||||
|
||||
---
|
||||
|
||||
## 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/`。
|
||||
|
||||
### 2.1 crate overview
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 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` 为准。 |
|
||||
|
||||
### 2.2 Module structure (`src/`)
|
||||
```
|
||||
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
|
||||
client.rs Client:认证、connect、session、send_request、event_source、ws
|
||||
core/ request / response / get / set / query / query_changes / changes / error / session
|
||||
email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…)
|
||||
email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询)
|
||||
mailbox/ helpers(mailbox_create/query/get/set/destroy…)
|
||||
thread/ thread/get
|
||||
identity/ Identity(发件身份)get/set
|
||||
blob/ blob 上传/复制(附件)
|
||||
sieve/ SieveScript(服务端过滤脚本)
|
||||
vacation_response/
|
||||
principal/ Principal(账户/共享)
|
||||
push_subscription/ PushSubscription(HTTP push 回调注册)
|
||||
event_source/ SSE 流:mod / parser / stream
|
||||
client_ws/ WebSocket 客户端(feature = "websockets")
|
||||
```
|
||||
|
||||
### 2.3 Key capabilities mapped to this Bot
|
||||
| Bot 需求 | jmap-client API | 备注 |
|
||||
|---|---|---|
|
||||
| 登录/会话 | `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 能力供调研 |
|
||||
|
||||
### 2.4 Authentication mechanism (`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.5 Error model (`Error` enumeration)
|
||||
```
|
||||
Transport(reqwest::Error) 网络/TLS/超时
|
||||
Parse(serde_json::Error) 序列化
|
||||
Internal(String) 客户端内部
|
||||
Problem(Box<ProblemDetails>) JMAP problem-details
|
||||
Server(String)
|
||||
Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…)
|
||||
Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败)
|
||||
WebSocket(...) ws 错误(feature 开启时)
|
||||
```
|
||||
- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。
|
||||
|
||||
### 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 能力调研参考。
|
||||
|
||||
| 通道 | 是否采用 | 备注 |
|
||||
|---|---|---|
|
||||
| **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` 冲突;且需服务端支持 |
|
||||
|
||||
对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication and email operation adaptation
|
||||
|
||||
### 3.1 Authentication Strategy (Confirmed: 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.2 Mailbox operation adaptation layer (JMAP ↔ Bot semantics)
|
||||
建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词:
|
||||
|
||||
| Bot 动词 | 封装函数 | 内部 JMAP |
|
||||
|---|---|---|
|
||||
| `list_folders()` | → `Vec<Folder>` | `mailbox_query` + `mailbox_get`(缓存 role→id) |
|
||||
| `list_emails(folder, page)` | → `Vec<EmailMetadata>` | `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) |
|
||||
| `set_flag(id, keyword)` | → () | `email_set` keywords |
|
||||
| `search_emails(account_id, query, limit)` | → `Result<Vec<SearchResult>, JmapError>` | `email_query`(`Filter::text`,`limit` 封顶 100)取 ID + `SearchSnippet/get` 取高亮;后者不支持(`unknownMethod`/超时)时**降级**返回空 snippets 而非报错,由调用方渲染纯 ID 列表 |
|
||||
|
||||
> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。
|
||||
|
||||
---
|
||||
|
||||
## 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`。
|
||||
|
||||
---
|
||||
|
||||
## 5. Overall architecture and data flow
|
||||
|
||||
### 5.1 High-level topology
|
||||
```
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ Telegram │
|
||||
└───────────────────────┬───────────────────────────┘
|
||||
Webhook(HTTPS) │
|
||||
┌───────────────────────▼───────────────────────────┐
|
||||
│ HTTP 入口 (axum,单端口 PORT) │
|
||||
│ POST /webhook/tg · POST /push/jmap · │
|
||||
│ POST /reconcile · GET /healthz /ready │
|
||||
│ GET / + admin session + config APIs (管理 SPA) │
|
||||
└───────────────────────┬───────────────────────────┘
|
||||
命令 │ 通知 ↓(Redis Streams worker)
|
||||
┌───────────────────────▼───────────────────────────┐
|
||||
│ bot 层 (handlers) │
|
||||
│ /list /read /send /search /folders /flag ... │
|
||||
└───────────────────────┬───────────────────────────┘
|
||||
领域 │
|
||||
┌───────────────────────▼───────────────────────────┐
|
||||
│ jmap 适配层 (JmapService) │
|
||||
│ list_folders/list_emails/read_email/send_email… │
|
||||
│ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │
|
||||
└───────────────────────┬───────────────────────────┘
|
||||
JMAP │ HTTPS(短请求)
|
||||
┌───────────────────────▼───────────────────────────┐
|
||||
│ Stalwart JMAP Server │
|
||||
│ session_url / email/* / mailbox/* / submission/* │
|
||||
└───────────────────────▲───────────────────────────┘
|
||||
Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE)
|
||||
┌───────────────────────┴───────────────────────────┐
|
||||
│ push handler → Redis Streams(MOD-STREAMS) │
|
||||
│ 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 类型;钉钉/飞书仅保留扩展位,不提前实现。
|
||||
|
||||
### 5.2 Channel abstraction and multi-channel expansion strategy
|
||||
- **现状**:首个(也是当前唯一)渠道是 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.3 Running model (short request, no long connection)
|
||||
- 单一二进制 `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<JmapService>`,内部 `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.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
|
||||
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
|
||||
→ read_email 封装 email_get(Subject, Preview, From, TextBody, BodyValues, Size, ReceivedAt, HasAttachment)→ EmailMetadata
|
||||
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
|
||||
→ channel::telegram::TelegramClient::send_notification(chat, notification)(内部渲染文本后经 send_text 发出)
|
||||
→ 更新 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`),非容器内自持定时器。
|
||||
|
||||
### 5.5 Data flow: command `/read <seq>` (long mail branch)
|
||||
```
|
||||
TG /read 3 → handler 取会话里的 folder+page 游标
|
||||
→ JmapService.list_emails(folder, page=3)
|
||||
→ 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM
|
||||
→ 格式化:
|
||||
正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape)
|
||||
正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项:
|
||||
"[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]"
|
||||
→ reply
|
||||
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
|
||||
```
|
||||
|
||||
### 5.6 Session State Machine (Conversation FSM)
|
||||
|
||||
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
|
||||
|
||||
仍成立的不变量: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)。
|
||||
|
||||
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
|
||||
|
||||
### 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` 高亮。
|
||||
|
||||
关键边界:
|
||||
- **高亮降级**:`SearchSnippet/get` 不支持(`unknownMethod`)或超时时返回空 snippets 而非报错;渲染降级为纯 ID 列表(`高亮片段暂不可用,以下为匹配的邮件 ID`),不发明片段。
|
||||
- **纯文本出站**:`<mark>` 高亮标记经 `strip_mark_tags`(大小写不敏感、未闭合标记丢弃)剥离后,再 `unescape_html_entities` 单趟解码;先剥标签再解码实体,防止邮箱正文里字面 `<mark>`(经服务器转义为 `<mark>`)被还原成真标签而误删。`subject`/`preview` 分别有 120/160 字符截断上限(`truncate_chars`,与正文截断同工具)。
|
||||
- **失败映射**:JMAP 侧失败 → `SearchReply::Retry` → `process_telegram` 返回 `Err(())` → 协调器 503 + `Retry-After` 重试(与 `/reconcile`、其他 JMAP 路径一致),**不 panic、不向用户回错误栈**。
|
||||
- **空查询**:`/search`(无关键词)→ 引导提示"请提供搜索关键词,例如:/search 发票",不发请求。无匹配 → `没有找到匹配「…」的邮件。`。
|
||||
- **每条命中带 `email_id`**,可直接接 `/summary <email_id>` 进入 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` 给友好报错。
|
||||
|
||||
---
|
||||
|
||||
## 6. Module division
|
||||
|
||||
实际工程结构(本文件所有代码锚点均指此结构;历史的目标模块拆分计划见 `docs/retired.md`):
|
||||
```
|
||||
message-weave/
|
||||
├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide
|
||||
├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置
|
||||
├── .gitignore
|
||||
├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 deployment.md §3)
|
||||
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非网关清单,见 §5.1
|
||||
├── src/
|
||||
│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
|
||||
│ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析
|
||||
│ ├── error.rs # BotError 统一错误(见 §8.1)
|
||||
│ ├── domain.rs # 领域层根(渠道中立,见 §5.2)
|
||||
│ ├── domain/jmap.rs # JmapBackend trait + MockBackend(契约测试不联网)
|
||||
│ ├── domain/jmap/client.rs # 包装 jmap_client::Client(真实只读 adapter,MOD-JMAP-CLIENT)
|
||||
│ ├── state.rs # Redis 读写封装:配置/开关/会话/TTL 键(C-REDIS-ONLY-STATE)
|
||||
│ ├── channel.rs # 渠道层:TelegramClient(reqwest 自研出站);领域类型在 src/domain.rs
|
||||
│ ├── notify.rs # /push/jmap 校验入队 + Redis Streams worker + /reconcile 对账
|
||||
│ ├── worker.rs # parse_intent → Intent,命令路由与 AI 授权判定
|
||||
│ ├── ai.rs # LlmClient / summarize:LLM 摘要调用
|
||||
│ └── web.rs # /config 静态前端路由 + include_str! 嵌入 + CSP
|
||||
├── web/ # 前端资源,构建期 include_str! 嵌入二进制(C-NO-LOCAL-WRITE)
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ ├── config.js
|
||||
│ └── config.test.mjs # Node 原生断言测试(非 cargo 测试)
|
||||
└── cloudflare-worker/ # 边缘反向代理,JS,不在 Rust 工程中
|
||||
```
|
||||
|
||||
测试:**没有集成测试目录**,也没有独立的 util 工具模块。所有 Rust 测试是各模块内的 `#[cfg(test)]` 单元测试;前端唯一测试是 `web/config.test.mjs`。
|
||||
|
||||
### 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`)。下表每一行都是**真实存在的模块**。
|
||||
|
||||
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|
||||
|---|---|---|---|---|
|
||||
| `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`) |
|
||||
|
||||
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`/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` 落地。
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration and Security
|
||||
|
||||
### 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列表仅作为历史
|
||||
> 兼容说明。
|
||||
|
||||
> **实际实现**(`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。
|
||||
|
||||
> ⚠️ **下表是业务配置字段的完整变量表**(变量名沿用原环境变量命名,便于与部署文档对照,但**代码中已无任何读取方**)。**当前生产部署只需要 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` 两个启动变量**(`src/main.rs`);其余业务字段全部经 Redis 业务配置由 `PUT /api/business-config` 写入(`C-REDIS-ONLY-STATE`)。表中「必填」指**Redis 业务配置**的 fail-closed 约束(`validate_nonblank`,空白即拒绝),不再是环境变量引导路径的必填。
|
||||
|
||||
| 变量 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
> 当前 `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::` 验证**。)
|
||||
|
||||
### 7.2 Key Management
|
||||
- **禁止**把 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.3 Access control
|
||||
- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**——
|
||||
- `/reconcile`:请求头 `Authorization: Bearer <RECONCILE_TOKEN>`;
|
||||
- `/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<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<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.1 Configuration Management 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`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。
|
||||
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "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` 只读取和写入非敏感运行参数:
|
||||
```json
|
||||
{
|
||||
"jmap_timeout_ms": 15000,
|
||||
"telegram_timeout_ms": 10000,
|
||||
"llm_timeout_ms": 30000,
|
||||
"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 和模型名。
|
||||
|
||||
### 7.4 TLS (code/dependent behavior)
|
||||
- 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.5 Production red line: no database / no local writing / standard output log (deployment.md §0/§8.2)
|
||||
|
||||
本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态:
|
||||
|
||||
| 红线 ID | 含义 | 违反示例(禁止) |
|
||||
|---|---|---|
|
||||
| `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<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
|
||||
|
||||
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
|
||||
|
||||
### 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。
|
||||
|
||||
---
|
||||
|
||||
## 8. Error handling and observability
|
||||
|
||||
### 8.1 Unified error enumeration
|
||||
|
||||
当前实现(`src/error.rs` 全文,4 个变体):
|
||||
|
||||
```rust
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BotError {
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("state error")]
|
||||
State(#[from] crate::state::StateError),
|
||||
}
|
||||
```
|
||||
|
||||
设计目标形态曾包含 `Jmap(#[from] jmap_client::Error)`、`Telegram(#[from] teloxide::errors::RequestError)`、`Storage(#[from] redis::RedisError)`、`RateLimited`、`Unauthorized(i64)`、`Llm(LlmErr)` 六个额外变体;`Telegram` 随 `teloxide` 未采用而废弃,其余至今未落地,完整清单见 `docs/retired.md`。
|
||||
|
||||
### 8.2 Error → User Message Mapping
|
||||
| 底层错误 | Bot 行为 |
|
||||
|---|---|
|
||||
| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" |
|
||||
| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 |
|
||||
| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" |
|
||||
| `Set(creation/update/destroy)` | 回复具体记录级失败原因 |
|
||||
| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) |
|
||||
| `Llm(*)` | 见 §12.7(熔断/回退/徽标) |
|
||||
|
||||
### 8.3 Observability
|
||||
- `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`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. Test strategy
|
||||
|
||||
### 9.1 Levels
|
||||
|
||||
| 层 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| 单元 | `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` 才跑 |
|
||||
|
||||
**没有集成测试目录**;前端唯一测试是 `web/config.test.mjs`(Node 原生断言)。
|
||||
|
||||
### 9.2 mock strategy
|
||||
- `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.3 Key invariant testing
|
||||
- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。
|
||||
- 通知去重:同一 `email_id` 不重复推送。
|
||||
- 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。
|
||||
- **安全边界断言(硬性,见 `docs/charter.md §3`)**:
|
||||
- VIEW/查看原文路径:mock AI 端点零请求(LLM client 未被调用);
|
||||
- 新邮件通知:消息内无正文内容(断言泄漏);
|
||||
- 长邮件:正文 > 4000 字符不发送全文;
|
||||
- AI 调用前置:未确认前 LLM 零调用;
|
||||
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
|
||||
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
|
||||
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
|
||||
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示就绪——检查配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap` 带 Basic 认证、`GET https://api.telegram.org/bot<token>/getMe`,各 3s、并行,最坏约 3s),任一失败返回标准错误 envelope(`service_unavailable` + `Retry-After: 30`),全过返回就绪报告 JSON;响应体不含敏感信息。Uptime Kuma 按状态码(`/ready` 期望 200)监控,不受响应体变化影响。
|
||||
- **渠道解耦**:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型;渠道装配集中在 `src/channel.rs`。
|
||||
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝;重定向仅信任配置里的 origin host(Stalwart 会 307 跳到 `/jmap/session`)。
|
||||
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Phased implementation plan
|
||||
|
||||
> 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。
|
||||
|
||||
### 10.0 Phase 0: Scaffolding and HTTPS entry skeleton (completed, `GATE-P0` passed)
|
||||
|
||||
> **当前实际边界**:单端口 axum 入口提供 `/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`。三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);`/reconcile` 已使用 JMAP `Email/changes` 分页、Redis `state:jmap:since` 和 Redis 单飞锁,只有全部事件入队成功后才推进游标;`/healthz` 为 liveness,`/ready` 做端到端探测(配置 + Redis + 出站只读探测,最坏约 3s),但不覆盖真实消息投递验收。
|
||||
|
||||
- **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1` + `ARCH-DEPS-STAGE4`):`axum 0.8`(单一 HTTPS 入口)、`async-trait`、`secrecy`、`subtle`(常数时间鉴权比较)、`serde`、`serde_json`、`thiserror`、`tokio`、`tracing`、`tracing-subscriber`、`url`(`JMAP_SESSION_URL` 归一化解析)、`jmap-client =0.4.2`(`default-features = false, features = ["async","rustls"]`)、`redis 0.27`(Redis XPING/PING 活性探测,`ARCH-STATE-REDIS`)、`reqwest 0.13`(JMAP/TG HTTP 客户端);dev-dependencies:`tower 0.5`(路由测试)。`teloxide` 未引入(`ARCH-DEPS-STAGE4`):Telegram 渠道在 `src/channel.rs` 用 reqwest 自研实现。
|
||||
- **尚未引入**(文档不得声称已用):`teloxide` 等任何 Telegram Bot 框架(Telegram 出站由 `src/channel.rs` 用 `reqwest` 直发,评估记录见 `docs/retired.md`)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。
|
||||
- **阶段1 依赖现况**(`ARCH-DEPS-STAGE1`):`jmap-client` **已引入**(当前 `=0.4.2`,`default-features = false, features = ["async","rustls"]`,版本与 features **以 `Cargo.toml` 为准**)。⚠️ 其默认 features `["async","websockets","aws_lc_rs"]` **含 WebSocket 栈**,故必须关闭默认 features 且不选 `websockets`;JMAP 仅走 **HTTPS 短请求**(Core/Mail),以遵守 `C-NO-LONG-CONN`(无 WS/SSE/长轮询)。
|
||||
- `ACCOUNT_ID` **可选**(`REQ-SINGLE-ACCOUNT`):**留空 → 取 JMAP session 的默认/主账户**;显式值经校验后使用;多账户 = 多个 bot 实例。
|
||||
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
|
||||
- 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。
|
||||
|
||||
**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 93 passed / 0 failed / 4 ignored(2026-09-28 复测;权威值见 `docs/charter.md` 的 `TEST_BASELINE` 行)。以下为阶段0 当时的判据,保留作历史记录:**
|
||||
1. `cargo fmt --check` 通过(无格式差异)。
|
||||
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。
|
||||
3. `cargo test` 通过(含路由/配置最小测试)。
|
||||
4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。
|
||||
5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。
|
||||
6. `RUN_MODE` **不**参与路由分派:`webhook` 与 `reconcile` 共享同一套路由表,`reconcile` 作为独立 HTTP 端点 `POST /reconcile` 提供。`RUN_MODE` 仅在启动时读取并校验取值,**当前不影响任何运行时行为**,留待后续阶段在此挂载差异化副作用(见 §12.2 的运行模式说明)。
|
||||
|
||||
> **阶段0 之后已演进的判据(当前口径,取代上述第 5、6 条的具体实现描述)**:
|
||||
> - 配置读取收窄为 **2 个进程级变量**(`REDIS_URL`、`CONFIG_ENCRYPTION_KEY`)由 `src/main.rs` 直读;
|
||||
> 业务配置改由 Redis 经 `config` 层反序列化,`Config::from_env()` 与全部遗留 env 解析器已删除(§7.1)。
|
||||
> - `RUN_MODE` **变量本身已删除**,代码中已无该标识符(登记见 `docs/retired.md`);第 6 条的
|
||||
> 「不参与路由分派」结论仍成立,但已无需变量承载。
|
||||
> - `JMAP_PASSWORD` 的**安全访问器** `jmap_password()` 已删除;`jmap_password` 作为业务配置字段名保留。
|
||||
7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。
|
||||
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
|
||||
|
||||
**运行监控(`GATE-UPTIME-KUMA`):**
|
||||
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 HTTP 200;`/ready` 最坏约 3s,探针超时需设 ≥10s;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。
|
||||
- `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz-worker` 作为存活探测,避免高频出站请求。
|
||||
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
|
||||
|
||||
**生产红线(贯穿所有阶段)**:
|
||||
- **无数据库**(`C-NO-DB`):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。
|
||||
- **无本地文件写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
|
||||
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):平台负责采集;禁用文件日志后端。
|
||||
- **日志/Redis 内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥、JMAP 邮件正文、AI 请求/响应、附件内容。
|
||||
- **无进程内恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅性能优化,丢失安全可重入。
|
||||
|
||||
**阶段1 推进边界(`BOUND-STAGE1`):**
|
||||
- 仅在 `GATE-P0` 全项通过后进入阶段1(JMAP 只读)。
|
||||
- **R1(入口真实鉴权)已落地**(`SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`):`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 按订阅 ID 校验 Redis 短期验证状态;常数时间比较,失败 `401` 且无副作用。缺少启动引导变量时服务进入配置引导模式,不伪造业务成功;业务配置完成后再启用对应入口。
|
||||
- **仍需注意**:本地 CI 门禁通过不等于真实 Stalwart、Redis、Telegram 链路已验收;公网部署前应完成真实端到端测试和外部监控配置。
|
||||
- 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。
|
||||
|
||||
### 10.1 Phase 1: JMAP Read Only (1.5d)
|
||||
|
||||
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
|
||||
|
||||
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 Redis 业务配置骨架(不引入独立的示例配置文件,见 `ARCH-CONFIG-ENV`)。
|
||||
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,并已用真实 Stalwart 实例通过 `cargo test -- --ignored jmap::` 验证(session 连接 → folder 列举 → 邮件列表 → 按 id 读取)**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**重定向信任**(仅信任配置里的 origin host,Stalwart 会 307 跳到 `/jmap/session`,否则 `jmap-client` 默认拒绝一切重定向)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
|
||||
- `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。
|
||||
- **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 及 `/api/push/register` 鉴权(`SAF-AUTH-*`)已落地并通过单测;后续改动不得放宽或绕过鉴权。
|
||||
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**
|
||||
- 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
|
||||
|
||||
### 10.2 Phase 2: Channel Adaptation (Completed)
|
||||
- `src/channel.rs` 实现 `TelegramClient`(`send_text` / `send_notification`,`reqwest` 自研);领域类型 `Notification` 在 `src/domain.rs`。
|
||||
- Telegram 装配用 `reqwest` 直发 `https://api.telegram.org/bot{token}/sendMessage`(**未引入 teloxide**;评估记录见 `docs/retired.md`)。
|
||||
- 入站:`POST /webhook/tg` 校验 secret token → 按 `update_id` 去重(`dedup:tg:{update_id}`)→ 入 Redis Streams → 2xx;出站为 Telegram 唯一用到的 Bot API 端点。
|
||||
- 意图路由:`src/worker.rs` 的 `parse_intent` 解析 6 种意图 —— `Help` / `Consent { ttl, label }` / `Summary(email_id)` / `Search(query)` / `Query` / `Unknown`,全部走自然语言触发词(AI 授权词见 `docs/reference.md` 的 AI 授权态一节)。`/search` 详见 §2.3、§5.7;中文搜索词只做**前缀匹配**(`搜索/查找/检索` + `/search`),避免覆盖授权与摘要意图。
|
||||
- 渲染在 adapter 内部:领域 Notification → TG Markdown/HTML + 转义。
|
||||
- 会话存储走外部 Redis(`C-REDIS-ONLY-STATE`;**不用 SQLite**)。
|
||||
- 验收:本地 webhook 形态,测试客户端发消息能得到回复;领域模块不导入任何渠道 SDK 类型。
|
||||
|
||||
### 10.3 Phase 3: Sending Email + Status Management (**Not implemented**)
|
||||
- `send_email`(draft + submission_set)**当前未实现**:`JmapBackend` 只有只读动词。
|
||||
- 发信流程原本计划用多步 FSM 收集 to/subject/body,**从未实现**(见 `docs/retired.md` 的对话 FSM 一节)。
|
||||
- `/flag /unseen` 关键词标记**未实现**。
|
||||
- 阶段 3 属**未实施范围**,不是缺口:`docs/opengaps.md` 只登记「仍未完成 / 仍未验证」的事项,未排期进入实施的未来阶段不在此列。
|
||||
|
||||
### 10.4 Phase 3.5: LLM facade + rollback (1.5d, not executed)
|
||||
未实施,见 `docs/retired.md`。
|
||||
|
||||
### 10.5 Implemented capabilities: real-time push (Push callback + Streams worker + external Cron reconciliation)
|
||||
`src/notify.rs` 是**单个近 2000 行的平铺文件**,没有子模块(下表中不存在 `notify::push_handler` / `notify::worker` / `notify::reconcile` / `mod_streams` / `mod_sincestate` 这些模块名,对应实现是文件内的自由函数):
|
||||
- `register_push`(`POST /api/push/register`):接受显式 HTTPS callback URL 并创建订阅;收到 Stalwart 推送时用 JMAP `PushSubscription/set` 回写 `verificationCode`。
|
||||
- `jmap_push`(`POST /push/jmap`):校验 subscription ID + verificationCode → 去重 → 入 Redis Streams → 2xx。
|
||||
- `worker`(`POST /worker`):Redis Streams 消费 → `Email/changes` 增量 → 通知 → 推进 sinceState → XACK。
|
||||
- `reconcile`(`POST /reconcile`):外部 HTTPS Cron 触发(`FLOW-RECONCILE`);兼做 Redis 丢失后的游标重建。**没有 CLI 子命令形态**。
|
||||
- sinceState 写外部 Redis(`state.rs`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。
|
||||
- 目标验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。当前代码门禁已通过,但仍需在真实 Stalwart、Redis 和 Telegram 环境完成端到端验证,不能将此处目标当作已验证事实。
|
||||
|
||||
### 10.5.1 Redis Streams event guarantee boundary (FLOW run semantics)
|
||||
|
||||
Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义如下:
|
||||
|
||||
**键与 TTL 的唯一权威来源是 `docs/reference.md`(Redis 键与 TTL 各节)。**本节不再重复表格——重复的表
|
||||
会被改坏:此前曾把 `push:subscription:{id}` 标成 7d、把 `push:registration:{...}` 标成 360s 注册单飞锁,
|
||||
两处都错,而真正的 360s 单飞锁 `lock:push-register:{sha256(callback_url)}` 当时整行缺失。现已收敛到
|
||||
单一权威表,本节只保留影响设计判断的四条"为什么":
|
||||
|
||||
- **对账锁 TTL 300s,刻意大于单页 120s 上限**,避免持锁期间锁过期导致同一账号重复进入对账
|
||||
(`REQ-RECONCILE-IDEMPOTENCY`、`SAF-RECONCILE-LOCK`)。
|
||||
- **push 注册单飞锁 TTL 360s,刻意大于单条出站请求上限 300s**,否则一次慢注册会放进重复请求
|
||||
(`SAF-AUTH-JMAP-PUSH`)。
|
||||
- **对账去重 24h 是有意设计**:同一封邮件 24h 内只通知一次(`MOD-DEDUP`,范围见本文 11.5)。
|
||||
- **配置键缺键即视为关闭(fail-closed)**,不进入业务路径。
|
||||
|
||||
**投递流程**(`notify::worker`,一次 XREADGROUP 批量 ≤10 条)
|
||||
|
||||
1. 读批 → 逐条 `process` → XACK。
|
||||
2. 处理中写入 `delivery:inflight`(60s)作租约;成功后写 `delivery:committed`(7d)。
|
||||
3. 崩溃于 `inflight` 租约窗口内的条目,由另一实例经 XAUTOCLAIM 回收重试(空闲阈值由运行超时配置推导,公式与上下限见 `docs/reference.md` §6.3)—— 至多重复、不丢。
|
||||
4. `retry_or_dlq`(`state.rs`):重试计数(`max_attempts.max(1)`)未到上限留在源流重试;达到上限则以**单个 Lua 脚本**原子地 `INCR`+`XADD`(入 DLQ)+`XACK`(源流确认),保证不会出现"源已 ACK 但既不在源也不在 DLQ"的缝隙。
|
||||
|
||||
**告警边界(当前实现,见 §8.3 监控约定)**
|
||||
|
||||
- 采用 Uptime Kuma HTTP(s) Monitor,不引入 Prometheus/exporter。
|
||||
- DLQ 条数、inflight 积压深度、对账补齐条数等指标尚未自动上报(§8.3 标记为可选)。当前运维需通过 Redis 直接查看:`XLEN messageweave:dlq:*`、`XPENDING` 等。
|
||||
- 若需自动化告警,建议在 Uptime Kuma 增加对 `/ready` 或对账入口的健康检查,并手动复核 DLQ 深度。
|
||||
|
||||
**已知边界(已收口,非缺口;`docs/opengaps.md` 当前无未完成项)**
|
||||
|
||||
- 多实例重复投递窗口(**已收口,`6c99ce5`**):XAUTOCLAIM 空闲阈值不再按固定值缩放,改由运行超时配置推导——`(max_retries + 1) × (jmap + telegram + llm 超时) × 2` 为单条上限,再乘批大小,下限 300s、上限 6h(见 `docs/reference.md` §6.3)。提前认领窗口在单实例与多实例部署下均关闭;单实例不受影响,多实例最多重复、不丢。
|
||||
|
||||
**审计意见 → 收口(2026-09-26)**
|
||||
|
||||
- 【应修-2】入队失败时 dedup 释放 best-effort 曾可能造成 24h 静默丢事件 → 已修复为 `claim_dedup_and_enqueue`(Lua 原子:`SET NX EX` 成功才 `XADD`),claim 与入队之间无中间失败窗口。
|
||||
- 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义已确认承重(`docs/opengaps.md` §2),真实 Stalwart 环境的回调验证往返与积压排空走的就是基线 → 增量 `/changes` → `newState` 这条路径(`docs/deployment.md` §4.1、§6.3.1)。
|
||||
- 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。
|
||||
- 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。
|
||||
|
||||
### 10.6 Total estimated time
|
||||
~10.5 人日(不含等待用户确认与真实联调排障)。
|
||||
|
||||
---
|
||||
|
||||
## 11. Archive of historical issues and decisions (product/architecture category, all have conclusions)
|
||||
|
||||
> ⚠️ 部署/平台类决策已**全部确认**(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账 / `Q-DEP-A` 平台 URL 与证书配置方 / `Q-DEP-B` 调度器选型),已归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。
|
||||
|
||||
### 11.1 Authentication method (confirmed)
|
||||
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
|
||||
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
|
||||
|
||||
### 11.2 Real-time channel (decided, see deployment.md)
|
||||
- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。
|
||||
- Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。回调重试次数与幂等键 TTL / 对账间隔的匹配属于运维调参项,不是验收门槛:`docs/deployment.md` §5 给重试建议、§6.3.1 给对账间隔取值、§7 说明幂等键 TTL 必须覆盖 TG rate-limit 回退上限。`docs/opengaps.md` 当前无未完成项。
|
||||
|
||||
### 11.3 Deployment form (confirmed, architecture related)
|
||||
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。2026-09-28 用户已就此决策,决定边界与将来若要做时的改动面登记在 `docs/opengaps.md` §3。
|
||||
- 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。
|
||||
- **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` 的 Worker 部署一节。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` 的多实例部署一节。
|
||||
|
||||
### 11.4 Early questions answered by code (no longer pending confirmation)
|
||||
以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论:
|
||||
- **消息格式**(Q6):出站只有 `send_text`(`src/channel.rs:114`),其载荷结构体 `SendMessage`(`src/channel.rs:60`)只有 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。
|
||||
- **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。
|
||||
- **附件**(Q8、Q28):完全未实现。领域模型只有 `has_attachment: bool`,出站只有 `sendMessage`,没有 `send_document` 也没有 `Blob/get`(见 §12.3)。
|
||||
- **推送范围**(Q9):对账拉全量 `changes`,Bot 侧不做文件夹/发件人/关键词过滤,也没有 Sieve 依赖(Q11 因此无影响)。
|
||||
- **摘要聚合 / 定时汇总**(Q10):未实现,只有 `/reconcile`。
|
||||
- **多发件身份**(Q12):`Identity` 未使用,单账户(`REQ-SINGLE-ACCOUNT`)。
|
||||
- **unsafe**(Q13):`src/` 中没有 `unsafe`,但也没有加 `#![forbid(unsafe_code)]`。
|
||||
- **监控**(Q15):实现为 `/healthz` + `/ready` 两个 HTTP 探针,不引入 Prometheus/Exporter;运行平台配置见 deployment.md。
|
||||
- **LLM 提供方 / 网出许可**(Q25、Q26、Q30):`LLM_BASE_URL` 由部署方指定(只校验 https);`LLM_ENABLED` 与 `LLM_ALLOW_NET` **默认均为 `false`**,二者须同时为真才构造客户端,否则 `llm` 字段为 `None`(`Option<Arc<LlmClient>>`,仓库内不存在 `noop()` 实现),不探测(见 §12.2)。
|
||||
- **熔断冷却 / 阈值**(Q27):不适用——熔断本身未实现(见 §12.4)。
|
||||
|
||||
部署/平台类决策已无未决项:`Q-DEP-A`(平台 URL / 域名与证书由谁配置,由部署环境在发布时确定)与 `Q-DEP-B`(外部 Cron 用哪个调度器,不限定实现)均已决策,归档到 `docs/deployment.md` 的已确认决策一节,不在本文重复。
|
||||
|
||||
---
|
||||
|
||||
## 12. AI auxiliary capabilities: architecture, confirmation threshold, failure fallback
|
||||
|
||||
> **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**:
|
||||
> 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。
|
||||
> 2. **查看原文始终 JMAP 直取**(`REQ-VIEW-DIRECT`):`Intent::Query` 与任何"看正文"动作走 `JmapService::read_email`,不走 LLM。
|
||||
> 3. **长邮件禁止直接发送完整原文**(`REQ-LONG-EMAIL`):正文超过阈值(默认 4000 字符,见 §12.3,可调)时,Bot **不直接发送全文**,而是提示"正文较长,请电脑查看"或"选择 AI 总结";AI 摘要目标约 **300 字**,**不做代码侧硬性数学截断**(由 LLM 自然生成 ~300 字)。
|
||||
> 4. **分析/摘要结果不持久化**(`REQ-ANALYSIS-EPHEMERAL`):不写缓存、不入 Redis、不留磁盘;每次请求即取即弃(无状态天然友好)。
|
||||
> 5. **附件按需拉取(保留)**(`REQ-ATTACH-ONDEMAND`):不预载;`Blob/get` 仅在用户点击下载时触发,单文件 ≤50MB 发文件,超限给 `download_url` 链接。
|
||||
> 6. **AI 失败约 3 次后需用户确认的非 AI 回退(保留)**(`REQ-AI-FUSE`):连续 3 次失败 → 熔断 + 向用户弹确认;选择"关闭 60s" 冷却期内直接走规则模板 + 原文直取路径;选择"恢复 AI" 则做一次探测。
|
||||
> 7. **OpenAI-compatible 环境变量(保留)**(`REQ-LLM-OPENAI-COMPAT`):`LLM_API_KEY / LLM_BASE_URL / LLM_MODEL` 等。
|
||||
>
|
||||
> 本节仅做设计,不写业务代码。
|
||||
|
||||
### 12.1 Module division (current implementation)
|
||||
LLM 相关代码只有**一个文件**:
|
||||
```
|
||||
src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions
|
||||
```
|
||||
配置装载在 `src/config.rs` 的 `LlmConfig`,运行时参数在 `src/state.rs` 的 `OutboundConfig`(经 `RuntimeConfigProvider` 下发)。
|
||||
LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。
|
||||
`summarize()` 只有**一个**调用点:`worker.rs` 的 `/summary` 已授权摘要分支。新邮件通知**不调用 LLM** —— `send_notification` 只渲染 `From:` / `Subject:` / `Received:` 三行元数据,正文永不外发(见 docs/reference.md §5.4)。
|
||||
|
||||
### 12.2 LLM variable list (current implementation)
|
||||
|
||||
> 下表 `LLM_*` 名称**已不是环境变量**:`Config::from_env()` 删除后代码对它们零读取,
|
||||
> 全部经 Redis 业务配置装载(§7.1)。表中「默认」为 `config` 层构造器内的回落值。
|
||||
| 变量 | 类型 | 默认 | 用途 |
|
||||
|---|---|---|---|
|
||||
| `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
|
||||
| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`src/main.rs:135-141`) |
|
||||
| `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 |
|
||||
| `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
|
||||
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |
|
||||
| `LLM_SUMMARY_TARGET_CHARS` | int | `300` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) |
|
||||
| `max_retries`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限见 `docs/reference.md` §6.1;LLM 与 Telegram 出站共用 |
|
||||
| `llm_timeout_ms`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发;单位毫秒,下限与取值范围见 `docs/reference.md` §6.1 |
|
||||
|
||||
**不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
|
||||
|
||||
### 12.3 Text acquisition strategy and long email processing (requirement 1/2/3/5)
|
||||
- **查看原文 = JMAP 直取**:`Intent::Query` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。
|
||||
- **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。
|
||||
- **没有"长邮件保护"逻辑**:不存在 4000 字符阈值、不存在 `Preview` 类型、不存在 `[继续查看原文]` 按钮。授权后的摘要路径就是把**全文**交给 LLM,LLM 失败时回退为前 300 字符,全程无截断标注。
|
||||
- 回退文本**没有任何标注**:`fallback()` 就是 `body.chars().take(300)` 直接返回,不追加"已截断"或"AI 不可用"字样。
|
||||
- **附件功能完全未实现**:`src/domain/jmap.rs` 明确注释"no mutation, attachment, AI, or streaming APIs",`read_email` 只返回正文文本;领域模型里唯一的附件信息是 `has_attachment: bool`。Telegram 出站只调 `sendMessage`,**不存在** `send_document` / `Blob/get` 流式下载 / 下载按钮。
|
||||
- AI 摘要的输入只有邮件正文文本,没有附件通道。
|
||||
|
||||
### 12.3.1 AI authorization period and Redis TTL (`REQ-AI-CONSENT`)
|
||||
- AI 授权必须由用户明确选择期限;可选 `临时一次`(一次授权窗口,3600 秒)、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。触发词按原文包含匹配(`临时`/`一次`/`今天`/`7天`/`直到我撤销`/`长期`,以及 `/ai on`、`/ai yes`、`/ai off`),不支持英文别名。
|
||||
- Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。
|
||||
- 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。
|
||||
|
||||
### 12.4 Failure detection (current implementation)
|
||||
`LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**:
|
||||
- 超时 `llm_timeout_ms`、重试次数 `max_retries`(单位、默认值、下限与硬上限均见 `docs/reference.md` §6.1;两项与 Telegram 出站共用同一配置)。
|
||||
- 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。
|
||||
- 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。
|
||||
- `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。
|
||||
|
||||
### 12.5 Non-AI fallback (current implementation)
|
||||
`worker.rs` 的 `fallback()` 就是**取正文前 300 个字符**,仅此而已:
|
||||
- AI 授权有效且 LLM 调用失败 → 静默换成这 300 字符,**不提示用户**、不加"AI 不可用"标记。
|
||||
- AI 未授权 → 不发摘要,回一条 `发件人 — 主题` + "授权已到期或尚未授权"。
|
||||
- 因此不存在熔断半开态、不存在确认回退的 inline-button、不存在 60s 冷却期、不存在写进 Redis 的熔断计数(Redis 上没有任何 LLM 相关状态键)。
|
||||
|
||||
### 12.6 Stateless/Containerized Adaptation
|
||||
- 摘要**不持久化、不缓存**:`worker.rs` 每次即时调用 LLM,结果不写 Redis、不落盘。与 `C-REDIS-ONLY-STATE` 一致。
|
||||
- 日志不记 prompt 明文与 API key(`SAF-LOG-PURITY`)——**但当前日志也不记任何 LLM 调用事件**(见 §8.3)。
|
||||
- 生效的运行参数只有 `llm_timeout_ms`、`max_retries`、`LLM_SUMMARY_TARGET_CHARS`(对应 `LlmClient::max_chars`,对摘要输出截断)。**不存在** `LLM_MAX_RETRIES` 这个环境变量。
|
||||
- 请求体只有 `model` / `messages` / `max_tokens`(`max_tokens = max_chars * 2`);**没有 temperature、没有 system prompt**——正文直接作为唯一的 user 消息发出。
|
||||
|
||||
### 12.7 Error handling and observability (continued from §8)
|
||||
**未实现** `BotError::Llm` 变体(`BotError` 只有 `Config`/`Io`/`Json`/`State` 四个),LLM 错误在 `worker.rs` 内被就地吞掉并降级为回退文本,不向上传播。
|
||||
**未实现** `llm.call` 的 `tracing` span(无 prompt 哈希、response 长度、latency 记录)。这两项连同 12.4 的熔断/半开设计、12.5 的规则回退与"AI 不可用"徽标,一并记入 `docs/retired.md`。
|
||||
|
||||
### "Phase 3.5" in the 12.8 plan has not been implemented
|
||||
原计划要在阶段 3 与 4 之间插入「阶段 3.5:LLM 门面 + 回退(1.5d)」,交付 `llm::client` / `llm::policy` / `llm::fallback` 三个模块,外加 4000 字符长邮件阈值、"AI 不可用"徽标和 3 次熔断 + Redis 共享计数。
|
||||
**这套计划没有按设计执行**:LLM 能力最终落在单文件 `src/ai.rs`(见 §12.1),没有门面、没有 policy、没有 fallback 模块,也没有徽标和阈值分支。被放弃的部分记入 `docs/retired.md`。
|
||||
本节原有一组 Q25–Q30 的设计提问也已作废——代码落地时已各自给出结论,答案见 §11.4。
|
||||
|
||||
---
|
||||
|
||||
## Attachment: Research basis (can be reviewed)
|
||||
- `stalwartlabs/jmap-client` main 分支(路径均相对该仓库根):`stalwartlabs/jmap-client/src/lib.rs`(URI/Method/DataType/Error)、`stalwartlabs/jmap-client/src/client.rs`(认证/连接/event_source)、`stalwartlabs/jmap-client/src/email/`、`stalwartlabs/jmap-client/src/email_submission/helpers.rs`、`stalwartlabs/jmap-client/src/event_source/`、`stalwartlabs/jmap-client/src/push_subscription/`(create/verify/update_types/destroy)、`stalwartlabs/jmap-client/src/core/error.rs`、`stalwartlabs/jmap-client/Cargo.toml`、`stalwartlabs/jmap-client/README.md`、`stalwartlabs/jmap-client/examples/`。
|
||||
- crates.io:`jmap-client` 元数据。
|
||||
- `stalwartlabs/mail-server` main 分支:`stalwartlabs/mail-server/crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`stalwartlabs/mail-server/crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`stalwartlabs/mail-server/crates/jmap/src/push/`、`stalwartlabs/mail-server/api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
|
||||
- 项目目录 `/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 `../AGENTS.md §3.3`)。
|
||||
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。
|
||||
130
docs/opengaps.md
130
docs/opengaps.md
|
|
@ -1,115 +1,117 @@
|
|||
# 未完成项(Open Gaps)
|
||||
# Unfinished items (Open Gaps)
|
||||
|
||||
本文件当前**无未完成项**:四条阶段目标全部关闭,唯一悬置的产品决策(多账户)已由用户于 2026-09-28 定为不做(§3)。保留关闭记录,便于回溯每个缺口为什么关、怎么关的。
|
||||
> [中文版本 / Chinese version → opengaps.zh-CN.md](opengaps.zh-CN.md)
|
||||
|
||||
已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。
|
||||
There are currently **no unfinished items** in this document: all four stage goals have been closed, and the only pending product decision (multiple accounts) has been determined not to be made by the user on 2026-09-28 (§3). Keep closing records to facilitate tracing why and how each gap was closed.
|
||||
|
||||
## 你要做的事
|
||||
The implemented interface and architecture design can be found in `docs/reference.md`, `docs/design.md`, `docs/deployment.md`, and the retired capabilities can be found in `docs/retired.md`. The verification baseline is based on the "P0 Access Control" section of `docs/design.md`.
|
||||
|
||||
**无。**
|
||||
## What you have to do
|
||||
|
||||
TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),不需要再交任何东西。
|
||||
**none. **
|
||||
|
||||
## 阶段目标
|
||||
The TTL green light for real machines has already been passed using the Upstash URL you gave (see §1), and there is no need to submit anything else.
|
||||
|
||||
| # | 项 | 状态 |
|
||||
## Stage goal
|
||||
|
||||
| # | item | status |
|
||||
| --- | --- | --- |
|
||||
| 1 | `Email/changes` 的 `newState` 语义 | 已关闭(提交 `269c8f6`) |
|
||||
| 2 | TTL 实测 | 已关闭(代码收口 `0890eb1` + 真机 `ttl` 两条断言跑通) |
|
||||
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `docs/deployment.md` §4.1) |
|
||||
| 4 | `/worker` 未在部署文档里 | 已关闭(`docs/deployment.md` §6.3.1 已补) |
|
||||
| 1 | `newState` semantics for `Email/changes` | Closed (commit `269c8f6`) |
|
||||
| 2 | TTL actual measurement | Closed (code closing `0890eb1` + real machine `ttl` two assertions run through) |
|
||||
| 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `docs/deployment.md` §4.1 for operation steps) |
|
||||
| 4 | `/worker` is not in the deployment document | Closed (`docs/deployment.md` §6.3.1 has been added) |
|
||||
|
||||
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **93 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 110 处行号锚点非空校验。
|
||||
Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **94 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 72 line number anchor non-null verifications.
|
||||
|
||||
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
|
||||
Item 5 is not within the stage goal: multi-account product decision-making, which has been determined not to be done by the user on 2026-09-28. The decision boundary and future changes will be discussed in §3.
|
||||
|
||||
## 阻塞
|
||||
## blocking
|
||||
|
||||
**无。** 四条阶段目标全部关闭,代码侧与真机侧都通了,唯一悬置的产品决策也已定案(§3)。
|
||||
**none. ** All four stage goals have been closed, both the code side and the real machine side have been connected, and the only pending product decision has been finalized (§3).
|
||||
|
||||
## 已关闭
|
||||
## Closed
|
||||
|
||||
### 1. TTL 实测(已关闭:常量收口 + 真机断言跑通)
|
||||
### 1. TTL actual measurement (closed: constant closing + real machine assertion run-through)
|
||||
|
||||
原条目:全代码库 **12 个 TTL 写入点、17 个 TTL 值**,但只有三个真正落到 Redis 的原子命令(`set_nx_ex` 的 `SET … NX EX`、`retry_or_dlq` Lua 里的 `EXPIRE`、`set_ai_consent` 的 `SET … EX`)在真实 Redis 上被断言过实际 PTTL;其余全是裸字面量。测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数,所以单测覆盖的是调用路径,不是过期时长——把一个 24 小时去重窗口改成 `60_480` 仍能编译通过、单测全绿。
|
||||
Original entry: Full code base **12 TTL write points, 17 TTL values**, but only three atomic commands that actually fall to Redis (`SET...NX EX` of `set_nx_ex`, `EXPIRE` of `retry_or_dlq` in Lua, `SET... EX` of `set_ai_consent`) are actually asserted on real Redis PTTL; the rest are all bare literals. The test double `MemoryState::claim_dedup` directly ignores the TTL parameter, so the single test covers the calling path, not the expiration length - changing a 24-hour deduplication window to `60_480` can still compile and pass, and the single test is all green.
|
||||
|
||||
三步关掉:
|
||||
Three steps closed the issue:
|
||||
|
||||
1. **真实 Redis 断言**(提交 `0890eb1`)。`src/state.rs` 的 `real_redis_ttl_tests`,两个 `#[ignore]`-gated 用例,照 `src/domain/jmap/client.rs:522` 的模式:缺 `REDIS_TEST_URL` 时打 skipped 并返回,URL 不落日志。断言三个原子写入点的实际 PTTL。运行:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`。
|
||||
2. **消灭裸字面量**(提交 `0890eb1` 之后的一轮)。17 个值全部收进 `src/state.rs` 的 `pub(crate) mod ttl`,调用点改引常量,新增 `ttl_contract_is_pinned` 逐个断言常量表,并带两条顺序断言(心跳必须短于锁、同意档位必须严格递增)。原条目里够不到的 B 类 Lua 内嵌值也接上了——`retry_or_dlq` 现在用 `ttl::RETRY_COUNTER_SECONDS` 插值生成脚本字符串,脚本里不再有第二个字面量。改值现在 = 改常量 + 断言失败,两处都得过评审。
|
||||
3. **真机绿灯**(本轮,用 Upstash 实例跑)。两条断言实际连上真实 Redis 读回 PTTL 并通过,B 类 Lua 内嵌值首次落到真机验证。过程见下文「测试自身的四个 bug」。
|
||||
1. **True Redis Assertion** (commit `0890eb1`). `real_redis_ttl_tests` of `src/state.rs`, two `#[ignore]`-gated use cases, follow the pattern of `src/domain/jmap/client.rs:522`: skipped and returned when `REDIS_TEST_URL` is missing, the URL will not be logged. Assert actual PTTL for three atomic write points. Run: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`.
|
||||
2. **Kill naked literals** (one round after commit `0890eb1`). All 17 values are included in `pub(crate) mod ttl` of `src/state.rs`, the call point changes the constant reference, and a new `ttl_contract_is_pinned` is added to assert the constant table one by one, with two sequential assertions (the heartbeat must be shorter than the lock, and the consent gear must be strictly increasing). Class B Lua embedded values that were out of reach in the original entry have also been connected - `retry_or_dlq` now uses `ttl::RETRY_COUNTER_SECONDS` interpolation to generate script strings, and there is no longer a second literal in the script. Changing the value now = changing the constant + assertion failure, both of which must be reviewed.
|
||||
3. **Real machine green light** (this round, run with Upstash instance). The two assertions were actually connected to the real Redis to read back the PTTL and passed. The Class B Lua embedded value was verified on a real machine for the first time. The process is shown below in "Testing the four TTL test fixes".
|
||||
|
||||
契约基线(键的完整语义见 `docs/reference.md` §1):
|
||||
Contract baseline (see `docs/reference.md` §1 for the complete semantics of keys):
|
||||
|
||||
| 常量 | 值(秒) | 键 |
|
||||
| constant | value (seconds) | key |
|
||||
| --- | --- | --- |
|
||||
| `ttl::RECONCILE_LOCK_SECONDS` | 300 | `lock:reconcile` |
|
||||
| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile`(心跳续租) |
|
||||
| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile` (heartbeat renewal) |
|
||||
| `ttl::PUSH_REGISTER_LOCK_SECONDS` | 360 | `lock:push-register:{sha256(callback_url)}` |
|
||||
| `ttl::PUSH_VERIFY_LIMIT_SECONDS` | 30 | `ratelimit:push-verify:{subscription_id}` |
|
||||
| `ttl::DEDUP_TG_SECONDS` | 86_400 | `dedup:tg:{update_id}` |
|
||||
| `ttl::DEDUP_JMAP_SECONDS` | 86_400 | `dedup:jmap:{account_id}:{email_id}`(回调入队与对账增量入队共用) |
|
||||
| `ttl::DEDUP_JMAP_SECONDS` | 86_400 | `dedup:jmap:{account_id}:{email_id}` (shared with callback enqueue and reconciliation incremental enqueue) |
|
||||
| `ttl::DELIVERY_INFLIGHT_SECONDS` | 60 | `delivery:inflight:{stream}:{message.id}` |
|
||||
| `ttl::DELIVERY_COMMITTED_SECONDS` | 604_800 | `delivery:committed:{stream}:{message.id}` |
|
||||
| `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status`(`disabled`) |
|
||||
| `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status`(`pending`) |
|
||||
| `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status`(`verified`) |
|
||||
| `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status` (`disabled`) |
|
||||
| `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status` (`pending`) |
|
||||
| `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status` (`verified`) |
|
||||
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` |
|
||||
| `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` |
|
||||
| `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` |
|
||||
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) |
|
||||
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}` (also the `expires_in` returned by `/admin/session`) |
|
||||
| `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` |
|
||||
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}`(「临时 / 一次」与显式 `/ai on` 共用) |
|
||||
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}`(「今天」) |
|
||||
| `ttl::CONSENT_WEEK_SECONDS` | 604_800 | `consent:ai:{chat_id}`(「7天」) |
|
||||
| `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}`(「直到撤销 / 长期」) |
|
||||
| `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}`(撤销;落 `max(1)` = 1s,语义是「已过期」) |
|
||||
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}` ("temporary/once" shared with explicit `/ai on`) |
|
||||
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}` ("Today") |
|
||||
| `ttl::CONSENT_WEEK_SECONDS` | 604_800 | `consent:ai:{chat_id}` ("7 days") |
|
||||
| `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}` ("until revoked / long term") |
|
||||
| `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}` (revoked; `max(1)` = 1s, semantics is "expired") |
|
||||
|
||||
生产代码里已经不存在裸 TTL 字面量;`worker.rs` 的同意档位测试断言仍保留数字字面量,那是**刻意的**——用独立于常量的期望值去验生产代码,否则两边一起改的话单测永远绿。
|
||||
Naked TTL literals no longer exist in production code; `worker.rs`'s agreed-upon test assertions still retain numeric literals, which is **deliberate** - use expected values independent of constants to test production code, otherwise if both sides are changed at the same time, the single test will always be green.
|
||||
|
||||
本轮顺手修掉测试自身的四个 bug。
|
||||
This update fixed four bugs in the test itself.
|
||||
|
||||
- 单测二进制不会跑 `main()`,rustls 没装 crypto provider,`rediss://` 握手直接 panic;现在测试模块自己调 `install_rustls_provider()`。
|
||||
- `retry:{stream}:{message.id}` 的键构造用消息 id 而不是 run id,断言读到一个从未写入的键,PTTL 返回 `-2`,测试第一句就失败。
|
||||
- `worker.rs` 对账增量入队那一处 TTL 字面量原先漏收,仍是裸 `86_400`。
|
||||
- The single test binary will not run `main()`, rustls does not have the crypto provider installed, and the `rediss://` handshake will directly panic; now the test module adjusts `install_rustls_provider()` by itself.
|
||||
- The key construction of `retry:{stream}:{message.id}` uses message id instead of run id. The assertion reads a key that has never been written. PTTL returns `-2` and the first sentence of the test fails.
|
||||
- `worker.rs` reconciles the TTL literal where the increment was added to the queue, which was originally missing and is still `86_400`.
|
||||
|
||||
第 4 个是真机断言自身不稳定的,前三个是编译期就能发现的:
|
||||
The fourth is that the real machine asserts that it is unstable. The first three can be discovered at compile time:
|
||||
|
||||
- 零值 case 原先断言 `assert_within(pttl, 1, 500)`——要求 1 秒 floor 读回来仍在 ±500 ms 内。但读回发生在跨网络 TLS 往返之后,那一秒通常已被消耗完,连跑三次拿到 `-2`(键已过期)、`269`、`118` ms。产品代码本身没问题:`SET … NX EX 1` 确实落地了,`-2` 恰恰证明 EX 生效、键按预期过期。改判据为「`pttl != -1`」——`-1` 才是真正要防的回归(EX 被静默丢弃、键永不过期),加上「`pttl == -2 || pttl <= 1000 + 2000`」这个只设上界的判断,因为一个还活着的值只会从 1s 往下数。改完连跑 6 轮全绿。
|
||||
- The zero value case originally asserted `assert_within(pttl, 1, 500)` - requiring the 1 second floor to be read back within ±500 ms. But the readback occurs after the cross-network TLS round trip, and that second is usually consumed. Running three times in a row got `-2` (key has expired), `269`, `118` ms. There is nothing wrong with the product code itself: `SET … NX EX 1` is indeed implemented, and `-2` just proves that EX takes effect and the keys expire as expected. Change the criterion to "`pttl != -1`" - `-1` is the real regression to prevent (EX is silently discarded, the key never expires), plus "`pttl == -2 || pttl <= 1000 + 2000`" is a judgment that only sets an upper bound, because a value that is still alive will only count down from 1s. After the change, I ran 6 consecutive rounds with all green.
|
||||
|
||||
**两条 TTL 断言已在真实 Upstash 实例上跑通(`rediss://`,TLS-only;明文 `redis://` 会在 `AUTH` 后收到空回复断连,故必须走 TLS)**:`SET … NX EX`(`claim_dedup`)、`SET … EX 3600`(`set_ai_consent`)、Lua 内嵌 `EXPIRE 86400`(`retry_or_dlq`)三处原子写入点的实际 PTTL 全部落在 2s 容差内。原条目里够不到的 B 类 Lua 内嵌值现在也是真机验证,不只是常量引用。
|
||||
**Two TTL assertions have been run on the real Upstash instance (`rediss://`, TLS-only; plaintext `redis://` will receive a null reply after `AUTH` and disconnect, so TLS must be used)**: `SET … NX EX` (`claim_dedup`), `SET … EX 3600` (`set_ai_consent`), Lua embedded `EXPIRE 86400` (`retry_or_dlq`) The actual PTTL of the three atomic write points all fall within the 2s tolerance. Class B Lua embedded values that were out of reach in the original article are now also verified on real machines, not just constant references.
|
||||
|
||||
### 2. `Email/changes` 的 `newState` 语义(提交 `269c8f6`)
|
||||
### 2. `newState` semantics of `Email/changes` (commit `269c8f6`)
|
||||
|
||||
原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。
|
||||
The original article worried that "the server returns `newState` when it cannot replay old increments, requiring the client to re-baseline". But jmap-client 0.4.2's `ChangesResponse` only has `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` plus expanded call parameters - **no reset / re-baseline signal field**. Therefore, the failure of `/changes` is just an ordinary `Err` in the code, and it is impossible to distinguish between "temporary failure" and "the server will no longer play back this state". This entry therefore cannot be closed by any observation means: what can be observed in the shutdown window is still the same `Err`.
|
||||
|
||||
代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。
|
||||
The actual behavior of the code is worse than the entry describes: `Err` is thrown up to `Err(())` as is, `reconcile:state` is left as is - the next cron tries again with the same dead `sinceState`, fails again, and gets another 503. The cursor is permanently frozen and `/reconcile` always returns `503 reconcile_retry` without any advancement.
|
||||
|
||||
**修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `session_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,`ttl::DEDUP_JMAP_SECONDS`)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。
|
||||
**Modification**: `/changes` will be re-baselined when it fails - take a fresh `session_state()`, write back `baseline:{fresh_state}:0` and return `Ok`. Position walk does not rely on the server-side changelog retention policy at all, so it can be recovered by re-walking from position 0 in the next round; the 24h deduplication key (`enqueue_reconcile_event`, `ttl::DEDUP_JMAP_SECONDS`) ensures that the replay will be repeated once at worst without leakage. Added single test `reconcile_rebaselines_when_changes_replay_is_stale` to lock this behavior.
|
||||
|
||||
> position walk 路径(`list_emails_page(None, position, BASELINE_PAGE_SIZE)`)本身不受影响——它的签名里根本没有服务端 state 参数(`None` 是 `folder_id`),所以不会碰到上面那类 token 语义。
|
||||
> The position walk path (`list_emails_page(None, position, BASELINE_PAGE_SIZE)`) itself is not affected - there is no server-side state parameter in its signature (`None` is `folder_id`), so it will not encounter the above type of token semantics.
|
||||
|
||||
### 3. 多账户(已决:不做,2026-09-28 用户决策)
|
||||
### 3. Multiple accounts (decided: not to do, 2026-09-28 user decision)
|
||||
|
||||
单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/deployment.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。
|
||||
Single accounts are fixed by requirement (`REQ-SINGLE-ACCOUNT`) and never enter the stage target. The user made it clear on 2026-09-28 that "no multiple accounts will be made for the time being" and this gap was closed. `docs/design.md` §11.3, the `REQ-SINGLE-ACCOUNT` line of `docs/charter.md` and the environment variable table/requirements mapping table of `docs/deployment.md` were originally expressed in this way, and these three places have not been changed in this round.
|
||||
|
||||
**这条决定覆盖的范围**
|
||||
**This article determines the scope of coverage**
|
||||
|
||||
- **不做多账户单实例**——一个 bot 实例同时服务多个 JMAP 账户。因此不需要 chat→account 路由、按域 / 按文件夹路由、`JmapService` 池化。
|
||||
- **需要第二个邮箱时的既有路径**:部署第二个 bot 实例,各自独立 `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` 与 Redis 前缀。这是已文档化的既有能力,不需要新代码(`docs/deployment.md`)。
|
||||
- **重新开启的触发条件**:出现「同一个 TG 会话要按来源邮箱分路由」这类单实例内的路由需求时,再拆 `REQ-SINGLE-ACCOUNT`。
|
||||
- **No multi-account single instance** - One bot instance serves multiple JMAP accounts at the same time. Therefore there is no need for chat→account routing, per-domain/per-folder routing, or `JmapService` pooling.
|
||||
- **Existing path when second mailbox is required**: Deploy a second bot instance, each with independent `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` and Redis prefix. This is an existing capability that is documented and requires no new code (`docs/deployment.md`).
|
||||
- **Trigger condition for re-opening**: When there is a routing requirement within a single instance such as "the same TG session needs to be routed according to the source mailbox", then dismantle `REQ-SINGLE-ACCOUNT`.
|
||||
|
||||
**将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用)
|
||||
**Minimum changes if necessary in the future** (The current situation has been verified in this round and will be used directly when disassembly is needed)
|
||||
|
||||
- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:777`、写 `src/state.rs:785`)——两个账户会互相覆盖游标,这是第一个要动的地方。
|
||||
- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1076`)——两个账户的对账会被同一把锁串行化。
|
||||
- `dedup:jmap:{account_id}:{email_id}` **已经带账户段**(`docs/reference.md` §1),键形无需改。
|
||||
- `ACCOUNT_ID` 环境变量已存在且经校验(留空取 session 主账户,越界值在 `src/domain/jmap/client.rs:485` 报错),业务配置层也能覆盖 `account_id`(`src/config.rs:465` 的 `unwrap_or_else` 回落)。配置面已就绪,缺的只是实例内的分维度状态。
|
||||
- `state:jmap:since` is a **single global cursor**, no account dimension (read `src/state.rs:777`, write `src/state.rs:785`) - both accounts will overwrite each other's cursors, which is the first place to move.
|
||||
- `lock:reconcile` is a **single global solo lock** (Redis implementation `src/state.rs:1076`) - the reconciliation of two accounts will be serialized by the same lock.
|
||||
- `dedup:jmap:{account_id}:{email_id}` **Already has the account segment** (`docs/reference.md` §1), the key shape does not need to be changed.
|
||||
- The `ACCOUNT_ID` environment variable already exists and has been verified (leave it blank to take the session main account, and an error will be reported if the out-of-bounds value is `src/domain/jmap/client.rs:485`). The business configuration layer can also override `account_id` (`unwrap_or_else` of `src/config.rs:465` falls back). The configuration interface is ready, all that is missing is the sub-dimension status within the instance.
|
||||
|
||||
这条不是被跳过,是被明确拒绝:单实例多账户省下的一个部署单元,换不来它引入的路由与游标复杂度。
|
||||
This item is not skipped, but explicitly rejected: the deployment unit saved by single instance and multiple accounts cannot be exchanged for the routing and cursor complexity it introduces.
|
||||
|
||||
## 本轮顺带发现并修掉的部署文档缺陷
|
||||
## Deployment document defects discovered and fixed in this round
|
||||
|
||||
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
|
||||
`docs/deployment.md` §6.3.1 Originally, only the scheduling example of `/reconcile` was given, and `/worker` - the only queue consumption entry in the entire code base - was described as "manual triggering of operation and maintenance" and was deliberately not included in the Worker whitelist. **Copying the deployment according to the document = emails continue to be queued and notifications are never sent out**. This is the reason why the joint debugging environment is backlogged for several days before being emptied manually. §6.3.1 The description of `/worker`’s scheduling steps, sequence requirements, batch limit and "204 cannot be used as a success signal" has been added.
|
||||
|
||||
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:340` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:390`。
|
||||
At the same time, the anchor points of `/worker` in `docs/reference.md` and `docs/retired.md` are corrected: the original value `notify.rs:340` falls in the `reconcile` function body, and the anchor point audit will not report an error (the line exists and is not empty), but the semantics are wrong; the correct value is `notify.rs:390`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
# 未完成项(Open Gaps)
|
||||
|
||||
> [English version / 英文版 → opengaps.md](opengaps.md)
|
||||
|
||||
本文件当前**无未完成项**:四条阶段目标全部关闭,唯一悬置的产品决策(多账户)已由用户于 2026-09-28 定为不做(§3)。保留关闭记录,便于回溯每个缺口为什么关、怎么关的。
|
||||
|
||||
已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。
|
||||
|
||||
## What you have to do
|
||||
|
||||
**无。**
|
||||
|
||||
TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),不需要再交任何东西。
|
||||
|
||||
## Stage goal
|
||||
|
||||
| # | 项 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `Email/changes` 的 `newState` 语义 | 已关闭(提交 `269c8f6`) |
|
||||
| 2 | TTL 实测 | 已关闭(代码收口 `0890eb1` + 真机 `ttl` 两条断言跑通) |
|
||||
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `docs/deployment.md` §4.1) |
|
||||
| 4 | `/worker` 未在部署文档里 | 已关闭(`docs/deployment.md` §6.3.1 已补) |
|
||||
|
||||
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **94 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 72 处行号锚点非空校验。
|
||||
|
||||
第 5 项不在阶段目标内:多账号产品决策,2026-09-28 已由用户定为不做,决定边界与将来要做的改动面见 §3。
|
||||
|
||||
## blocking
|
||||
|
||||
**无。** 四条阶段目标全部关闭,代码侧与真机侧都通了,唯一悬置的产品决策也已定案(§3)。
|
||||
|
||||
## Closed
|
||||
|
||||
### 1. TTL actual measurement (closed: constant closing + real machine assertion run-through)
|
||||
|
||||
原条目:全代码库 **12 个 TTL 写入点、17 个 TTL 值**,但只有三个真正落到 Redis 的原子命令(`set_nx_ex` 的 `SET … NX EX`、`retry_or_dlq` Lua 里的 `EXPIRE`、`set_ai_consent` 的 `SET … EX`)在真实 Redis 上被断言过实际 PTTL;其余全是裸字面量。测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数,所以单测覆盖的是调用路径,不是过期时长——把一个 24 小时去重窗口改成 `60_480` 仍能编译通过、单测全绿。
|
||||
|
||||
三步关掉:
|
||||
|
||||
1. **真实 Redis 断言**(提交 `0890eb1`)。`src/state.rs` 的 `real_redis_ttl_tests`,两个 `#[ignore]`-gated 用例,照 `src/domain/jmap/client.rs:522` 的模式:缺 `REDIS_TEST_URL` 时打 skipped 并返回,URL 不落日志。断言三个原子写入点的实际 PTTL。运行:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`。
|
||||
2. **消灭裸字面量**(提交 `0890eb1` 之后的一轮)。17 个值全部收进 `src/state.rs` 的 `pub(crate) mod ttl`,调用点改引常量,新增 `ttl_contract_is_pinned` 逐个断言常量表,并带两条顺序断言(心跳必须短于锁、同意档位必须严格递增)。原条目里够不到的 B 类 Lua 内嵌值也接上了——`retry_or_dlq` 现在用 `ttl::RETRY_COUNTER_SECONDS` 插值生成脚本字符串,脚本里不再有第二个字面量。改值现在 = 改常量 + 断言失败,两处都得过评审。
|
||||
3. **真机绿灯**(本轮,用 Upstash 实例跑)。两条断言实际连上真实 Redis 读回 PTTL 并通过,B 类 Lua 内嵌值首次落到真机验证。过程见下文「测试自身的四个 bug」。
|
||||
|
||||
契约基线(键的完整语义见 `docs/reference.md` §1):
|
||||
|
||||
| 常量 | 值(秒) | 键 |
|
||||
| --- | --- | --- |
|
||||
| `ttl::RECONCILE_LOCK_SECONDS` | 300 | `lock:reconcile` |
|
||||
| `ttl::RECONCILE_HEARTBEAT_SECONDS` | 90 | `lock:reconcile`(心跳续租) |
|
||||
| `ttl::PUSH_REGISTER_LOCK_SECONDS` | 360 | `lock:push-register:{sha256(callback_url)}` |
|
||||
| `ttl::PUSH_VERIFY_LIMIT_SECONDS` | 30 | `ratelimit:push-verify:{subscription_id}` |
|
||||
| `ttl::DEDUP_TG_SECONDS` | 86_400 | `dedup:tg:{update_id}` |
|
||||
| `ttl::DEDUP_JMAP_SECONDS` | 86_400 | `dedup:jmap:{account_id}:{email_id}`(回调入队与对账增量入队共用) |
|
||||
| `ttl::DELIVERY_INFLIGHT_SECONDS` | 60 | `delivery:inflight:{stream}:{message.id}` |
|
||||
| `ttl::DELIVERY_COMMITTED_SECONDS` | 604_800 | `delivery:committed:{stream}:{message.id}` |
|
||||
| `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status`(`disabled`) |
|
||||
| `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status`(`pending`) |
|
||||
| `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status`(`verified`) |
|
||||
| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 300 | `push:subscription:{id}` / `push:subscription-code:{code}` |
|
||||
| `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` |
|
||||
| `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` |
|
||||
| `ttl::ADMIN_SESSION_SECONDS` | 900 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) |
|
||||
| `ttl::RETRY_COUNTER_SECONDS` | 86_400 | `retry:{stream}:{message.id}` |
|
||||
| `ttl::CONSENT_TEMPORARY_SECONDS` | 3_600 | `consent:ai:{chat_id}`(「临时 / 一次」与显式 `/ai on` 共用) |
|
||||
| `ttl::CONSENT_TODAY_SECONDS` | 86_400 | `consent:ai:{chat_id}`(「今天」) |
|
||||
| `ttl::CONSENT_WEEK_SECONDS` | 604_800 | `consent:ai:{chat_id}`(「7天」) |
|
||||
| `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}`(「直到撤销 / 长期」) |
|
||||
| `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}`(撤销;落 `max(1)` = 1s,语义是「已过期」) |
|
||||
|
||||
生产代码里已经不存在裸 TTL 字面量;`worker.rs` 的同意档位测试断言仍保留数字字面量,那是**刻意的**——用独立于常量的期望值去验生产代码,否则两边一起改的话单测永远绿。
|
||||
|
||||
本轮顺手修掉测试自身的四个 bug。
|
||||
|
||||
- 单测二进制不会跑 `main()`,rustls 没装 crypto provider,`rediss://` 握手直接 panic;现在测试模块自己调 `install_rustls_provider()`。
|
||||
- `retry:{stream}:{message.id}` 的键构造用消息 id 而不是 run id,断言读到一个从未写入的键,PTTL 返回 `-2`,测试第一句就失败。
|
||||
- `worker.rs` 对账增量入队那一处 TTL 字面量原先漏收,仍是裸 `86_400`。
|
||||
|
||||
第 4 个是真机断言自身不稳定的,前三个是编译期就能发现的:
|
||||
|
||||
- 零值 case 原先断言 `assert_within(pttl, 1, 500)`——要求 1 秒 floor 读回来仍在 ±500 ms 内。但读回发生在跨网络 TLS 往返之后,那一秒通常已被消耗完,连跑三次拿到 `-2`(键已过期)、`269`、`118` ms。产品代码本身没问题:`SET … NX EX 1` 确实落地了,`-2` 恰恰证明 EX 生效、键按预期过期。改判据为「`pttl != -1`」——`-1` 才是真正要防的回归(EX 被静默丢弃、键永不过期),加上「`pttl == -2 || pttl <= 1000 + 2000`」这个只设上界的判断,因为一个还活着的值只会从 1s 往下数。改完连跑 6 轮全绿。
|
||||
|
||||
**两条 TTL 断言已在真实 Upstash 实例上跑通(`rediss://`,TLS-only;明文 `redis://` 会在 `AUTH` 后收到空回复断连,故必须走 TLS)**:`SET … NX EX`(`claim_dedup`)、`SET … EX 3600`(`set_ai_consent`)、Lua 内嵌 `EXPIRE 86400`(`retry_or_dlq`)三处原子写入点的实际 PTTL 全部落在 2s 容差内。原条目里够不到的 B 类 Lua 内嵌值现在也是真机验证,不只是常量引用。
|
||||
|
||||
### 2. `newState` semantics of `Email/changes` (commit `269c8f6`)
|
||||
|
||||
原条目担心「服务器无法回放旧增量时返回 `newState`,要求客户端重新基线化」。但 jmap-client 0.4.2 的 `ChangesResponse` 只有 `accountId` / `oldState` / `newState` / `hasMoreChanges` / `created` / `updated` / `destroyed` 加展开的调用参数——**没有 reset / re-baseline 信号字段**。所以 `/changes` 失败在代码里就是一个普通的 `Err`,无法区分「临时故障」和「服务端不再回放这个 state」。这个条目因此无法通过任何观测手段闭环:停机窗口里能观测到的仍然是同一个 `Err`。
|
||||
|
||||
代码的实际行为比条目描述的更糟:`Err` 被原样上抛成 `Err(())`,`reconcile:state` 原样保留——下一次 cron 用同一个 dead `sinceState` 重试,再失败,再 503。游标永久冻结,`/reconcile` 永远返回 `503 reconcile_retry` 且没有任何前进。
|
||||
|
||||
**修法**:`/changes` 失败时改走重新基线——取一个新鲜的 `session_state()`,写回 `baseline:{fresh_state}:0` 并 `Ok` 返回。position walk 完全不依赖服务端 changelog 保留策略,所以下一轮从 position 0 重走一遍即可恢复;24h 去重键(`enqueue_reconcile_event`,`ttl::DEDUP_JMAP_SECONDS`)保证重放最坏重复一次、不会漏。新增单测 `reconcile_rebaselines_when_changes_replay_is_stale` 锁定该行为。
|
||||
|
||||
> position walk 路径(`list_emails_page(None, position, BASELINE_PAGE_SIZE)`)本身不受影响——它的签名里根本没有服务端 state 参数(`None` 是 `folder_id`),所以不会碰到上面那类 token 语义。
|
||||
|
||||
### 3. Multiple accounts (decided: not to do, 2026-09-28 user decision)
|
||||
|
||||
单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/deployment.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。
|
||||
|
||||
**这条决定覆盖的范围**
|
||||
|
||||
- **不做多账户单实例**——一个 bot 实例同时服务多个 JMAP 账户。因此不需要 chat→account 路由、按域 / 按文件夹路由、`JmapService` 池化。
|
||||
- **需要第二个邮箱时的既有路径**:部署第二个 bot 实例,各自独立 `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` 与 Redis 前缀。这是已文档化的既有能力,不需要新代码(`docs/deployment.md`)。
|
||||
- **重新开启的触发条件**:出现「同一个 TG 会话要按来源邮箱分路由」这类单实例内的路由需求时,再拆 `REQ-SINGLE-ACCOUNT`。
|
||||
|
||||
**将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用)
|
||||
|
||||
- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:777`、写 `src/state.rs:785`)——两个账户会互相覆盖游标,这是第一个要动的地方。
|
||||
- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1076`)——两个账户的对账会被同一把锁串行化。
|
||||
- `dedup:jmap:{account_id}:{email_id}` **已经带账户段**(`docs/reference.md` §1),键形无需改。
|
||||
- `ACCOUNT_ID` 环境变量已存在且经校验(留空取 session 主账户,越界值在 `src/domain/jmap/client.rs:485` 报错),业务配置层也能覆盖 `account_id`(`src/config.rs:465` 的 `unwrap_or_else` 回落)。配置面已就绪,缺的只是实例内的分维度状态。
|
||||
|
||||
这条不是被跳过,是被明确拒绝:单实例多账户省下的一个部署单元,换不来它引入的路由与游标复杂度。
|
||||
|
||||
## Deployment document defects discovered and fixed in this round
|
||||
|
||||
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
|
||||
|
||||
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:340` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:390`。
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# MessageWeave — Reference
|
||||
|
||||
> [中文版本 / Chinese version → reference.zh-CN.md](reference.zh-CN.md)
|
||||
|
||||
> **This file is the single source of truth for verifiable facts.**
|
||||
> When any other document disagrees with this one, this one wins.
|
||||
>
|
||||
|
|
@ -216,6 +218,7 @@ admin route; §5.1 explains why that is deliberate.
|
|||
| POST | `/webhook/tg` | `telegram_webhook` |
|
||||
| POST | `/push/jmap` | `jmap_push` |
|
||||
| POST | `/api/push/register` | `register_push` |
|
||||
| POST | `/api/telegram/register-webhook` | `register_telegram_webhook` |
|
||||
| POST | `/api/push/disable` | `disable_push` |
|
||||
| POST | `/reconcile` | `reconcile` |
|
||||
| POST | `/worker` | `worker` |
|
||||
|
|
@ -294,22 +297,20 @@ all: a path missing from `SAFE_ROUTES` returns **404**, a registered
|
|||
path with the wrong method returns **405**, and a missing or unparseable
|
||||
backend pool returns **503** rather than passing the request through.
|
||||
|
||||
**Forwarded by the worker (16):**
|
||||
**Forwarded by the worker (19):**
|
||||
|
||||
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
|
||||
`/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` ·
|
||||
`/api/admin/session/revoke` ·
|
||||
`/api/enabled` ·
|
||||
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
|
||||
`/ready`
|
||||
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/telegram/register-webhook` · `/api/push/disable` · `/reconcile` ·
|
||||
`/worker` · `/ready` · `/healthz`
|
||||
|
||||
**Registered on the backend but NOT forwarded (4):**
|
||||
**Registered on the backend but NOT forwarded (2):**
|
||||
|
||||
| Path | Why it is absent from the gateway |
|
||||
|---|---|
|
||||
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
|
||||
| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path |
|
||||
| `GET /healthz` | Liveness is aggregated by the gateway itself |
|
||||
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly |
|
||||
|
||||
`GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the
|
||||
|
|
@ -318,18 +319,17 @@ reads it, the toggle writes it); both calls are
|
|||
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded
|
||||
`/api/admin/session` pair.
|
||||
|
||||
Consequence: none of the four carries external business traffic, so no second ingress is
|
||||
Consequence: neither remaining route carries external business traffic, so no second ingress is
|
||||
needed in front of the backend instances. The SPA's first-boot flow still cannot drive
|
||||
`/api/bootstrap` through the worker — bootstrap must be performed against the backend origin
|
||||
directly, or the bootstrap path must be added to the gateway allowlist.
|
||||
|
||||
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
|
||||
`POST /api/push/register`, `POST /api/telegram/register-webhook` and `POST /api/push/disable` **are** forwarded. These are safe to
|
||||
proxy: the callback URL is supplied by the client in the request body and
|
||||
validated as a URL before anything is written, and every push subscription record is
|
||||
written to and read back from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`),
|
||||
so it does not matter which backend instance the worker picks. Push
|
||||
registration therefore no longer requires hitting a specific backend address — see
|
||||
deployment.md §10.5.
|
||||
registration therefore no longer requires hitting a specific backend address. After an operator saves business configuration, the SPA submits both callback URLs through these protected endpoints.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -649,3 +649,117 @@ External dependencies, all reachable and returning HTTP 200 at the time of verif
|
|||
Not linked because they do not resolve to documentation: `crates.io/crates/jmap-client`
|
||||
(404) and `platform.openai.com` documentation paths (403). The project describes the LLM
|
||||
dependency as OpenAI-compatible and documents it through `reqwest` instead.
|
||||
|
||||
## 9. Deployment platform details
|
||||
|
||||
The operator-facing sequence is in [`deployment.md`](deployment.md). This section keeps
|
||||
platform-specific behavior and settings that are useful when configuring or diagnosing a
|
||||
deployment.
|
||||
|
||||
### 9.1 Backend deployment paths
|
||||
|
||||
**HostStack production path.** The repository's `hoststack.yaml` is the production
|
||||
configuration for HostStack's native Rust runtime. It runs `cargo fetch --locked`, builds
|
||||
with `cargo build --release --locked`, starts `./target/release/message-weave`, and uses
|
||||
`/healthz` for liveness. Set `REDIS_URL` and `CONFIG_ENCRYPTION_KEY` as encrypted service
|
||||
secrets. The service commands declared in YAML override stored dashboard commands; removing
|
||||
the file can restore HostStack's default `./target/release/app`, which is not this package's
|
||||
binary. Keep the YAML checked in unless all dashboard commands have been corrected and a
|
||||
replacement deployment has been verified.
|
||||
|
||||
The HostStack path does not execute the repository Dockerfile. Its builder and runner are
|
||||
managed by the platform. The Dockerfile is for local container runs and Docker-based hosts.
|
||||
|
||||
**Docker image path.** The root Dockerfile has two stages:
|
||||
|
||||
1. `rust:1-slim-bookworm` builds the release binary with the locked Cargo dependencies.
|
||||
2. `debian:bookworm-slim` receives that binary, CA certificates, `tini`, and read-only
|
||||
diagnostic tools (`curl`, `procps`, `iproute2`, `jq`, and `netcat-openbsd`). It creates
|
||||
and runs as the `messageweave` system user, sets `PORT=8080`, exposes only that port, and
|
||||
starts the service through `tini`.
|
||||
|
||||
The image has no Redis process, database, or persistent data volume. Runtime configuration is
|
||||
injected by the host; secrets do not enter build arguments or image layers. Docker-platform
|
||||
health checks should call the HTTP endpoints described in [`deployment.md`](deployment.md).
|
||||
|
||||
### 9.2 Backend configuration and secrets
|
||||
|
||||
The process reads only `REDIS_URL` and `CONFIG_ENCRYPTION_KEY` at startup. The optional
|
||||
remote-debug surface uses the independent `DEBUG_ENABLED` switch and `DEBUG_TOKEN`; leave
|
||||
both unset in production unless remote diagnostics are deliberately enabled. Full startup
|
||||
variable semantics and Redis business configuration fields are in [§5](#5-environment-variables).
|
||||
|
||||
Telegram, JMAP, allowlist, worker, reconcile, and LLM business settings are Redis-resident,
|
||||
not process environment variables. The field list and validation rules in [§5.2](#52-redis-resident-business-configuration)
|
||||
are authoritative. A successful initial save can use the one-shot `/api/bootstrap` endpoint
|
||||
on the backend origin; that route is not exposed through the Worker. Subsequent edits use the
|
||||
protected configuration API.
|
||||
|
||||
### 9.3 Callback registration and scheduled work
|
||||
|
||||
Telegram's `setWebhook` request must use the configured webhook secret and include `message`
|
||||
in `allowed_updates`. Check the resulting URL with `getWebhookInfo`; Telegram does not return
|
||||
the secret in that response. Re-run `setWebhook` after rotating the secret.
|
||||
|
||||
Register Stalwart push with `POST /api/push/register` and an HTTPS `callback_url`. The backend
|
||||
creates the subscription and completes Stalwart's verification callback. Repeating the same
|
||||
callback URL is idempotent. Use `POST /api/push/disable` with that URL to remove it.
|
||||
|
||||
The backend has no internal scheduler. [`scripts/cron-drain.sh`](../scripts/cron-drain.sh)
|
||||
calls `/reconcile` (when `MW_RECONCILE_TOKEN` is set) and then `/worker`. Configure
|
||||
`MW_APP_URL`, `MW_WORKER_TOKEN`, and optionally `MW_RECONCILE_TOKEN` in the external
|
||||
scheduler's secret store. `MW_WORKER_URL` can override the default when the operator needs
|
||||
to call a backend origin directly. `--once` is for cron; `--loop` is for a managed process.
|
||||
The script also provides `--diagnose` and `--test-notify` for troubleshooting. Both successful
|
||||
drain endpoints return `204` with an empty response body; `/reconcile` can return `409` while
|
||||
another reconciliation owns the lock.
|
||||
|
||||
### 9.4 Health and release checks
|
||||
|
||||
- `GET /healthz` is backend liveness. It does not prove Redis or upstream services are ready.
|
||||
- `GET /ready` checks configuration, Redis, the JMAP session, and Telegram's `getMe` endpoint.
|
||||
It returns `503` with `Retry-After` when a dependency is unavailable. The upstream checks
|
||||
need outbound HTTPS access from the backend.
|
||||
- `GET /api/status` reports boot readiness and missing required boot-variable names.
|
||||
- `GET /healthz-worker` is generated by the Worker. Check both `available >= 1` and `version`;
|
||||
HTTP 200 alone can mean `no-backends` when the origin configuration is missing.
|
||||
|
||||
For the backend code gate, use the locked commands and container environment in
|
||||
[`charter.md`](charter.md). The documentation gate must run on the host so its
|
||||
path audit can read Git history.
|
||||
|
||||
### 9.5 Cloudflare Worker and Dashboard
|
||||
|
||||
The Worker is an optional HTTPS gateway. The backend origins must be HTTPS strings, with no
|
||||
path, query, fragment, or embedded credentials. The current public origin list and
|
||||
`LB_VERSION` are declared in `cloudflare-worker/wrangler.toml` under `[vars]`. Move
|
||||
`BACKEND_ORIGINS_JSON` to an encrypted secret only when its values are private; never store
|
||||
credentials in that list. The Worker itself does not hold Telegram, JMAP, Redis, or backend
|
||||
business credentials.
|
||||
|
||||
| Setting | Dashboard value | Notes |
|
||||
|---|---|---|
|
||||
| Root directory | `cloudflare-worker` | The repository root contains the Rust service, not the Worker configuration. |
|
||||
| Application name | `messageweave-lb` | Match the `name` in `wrangler.toml`. |
|
||||
| Build command | Leave empty | Wrangler bundles the Worker during deploy; this field is not the test command. |
|
||||
| Deploy command | `npx wrangler deploy` | Keep this command in the required deploy field. |
|
||||
| Preview command | `npx wrangler dev --ip 0.0.0.0 --port 8787` | Use when preview builds are enabled; `wrangler preview` is not a valid command. |
|
||||
|
||||
In the Dashboard, add Worker tuning values under **Settings → Variables & Secrets → Add
|
||||
Variable**. Defaults are `LB_REQUEST_TIMEOUT_MS=10000`, `LB_MAX_ATTEMPTS=2`,
|
||||
`LB_RECONCILE_TIMEOUT_MS=320000`, `LB_WORKER_TIMEOUT_MS=300000`, and
|
||||
`LB_HEALTH_TTL_MS=30000`. These are adjustable variables, not secrets. `LB_VERSION` belongs
|
||||
in `[vars]` so the deployed version stays visible in source control.
|
||||
|
||||
If a private origin list must be stored in the Dashboard, use **Encrypt** and add
|
||||
`BACKEND_ORIGINS_JSON` under **Settings → Variables & Secrets**; remove its `[vars]` entry.
|
||||
Changes to encrypted variables take effect without a code rebuild. Preview environments only
|
||||
receive secrets configured for that environment; a preview without the origin list can start
|
||||
but cannot verify backend forwarding.
|
||||
|
||||
For Git integration, push the selected commit to the GitHub repository connected to
|
||||
Cloudflare after setting the `cloudflare-worker` root directory. The build command can be
|
||||
empty; run `npm test` separately as the Worker code check. After deploy, request
|
||||
`/healthz-worker`, confirm `available` is at least one, and check that `version` matches
|
||||
`LB_VERSION`. `status: no-backends` with HTTP 200 means the Worker has no usable origin list;
|
||||
`status: down` with HTTP 503 means all configured backends failed their health check.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,765 @@
|
|||
# MessageWeave — 参考
|
||||
|
||||
> [English version / 英文版 → reference.md](reference.md)
|
||||
|
||||
> **此文件是可验证事实的单一事实来源。**
|
||||
> 当任何其他文件与本文件不一致时,以本文件为准。
|
||||
>
|
||||
> **根据设计,该文件不包含源行号。** 它们不属于
|
||||
> 验证:此文件记录*哪个符号*执行*什么*,行号是
|
||||
> 对同一文件的每次不相关的编辑都会重新编号。 `docs/deployment.md` 保留其
|
||||
> 行号,因为该文件是操作员在事件中读取的运行手册。
|
||||
>
|
||||
> **维护责任。** 对 `src/config.rs` 公共 API 的任何更改,
|
||||
> `src/state.rs`、`src/worker.rs` 或 `src/notify.rs`,或 **任何 Redis 键名称或 TTL**,
|
||||
> 必须在同一更改中更新此文件。审阅者:拒绝涉及这些的更改
|
||||
> 文件而不触及此文件。
|
||||
|
||||
全文使用的约定:
|
||||
|
||||
- **TTL** 以秒为单位。 “No EX”意味着密钥永不过期,并且在明确删除之前一直有效。
|
||||
- **写入器/读取器** 命名执行操作的代码符号。符号名称是
|
||||
身份;这个文件故意不记录行号(见上面的注释),所以
|
||||
重新编号后仍然如此。
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1. Redis keys
|
||||
|
||||
### 1.1 Configuration
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `配置:业务` |没有 EX | `set_business_config`;首次通过“initialize_business_config”中的“SET NX”写入 | `获取业务配置` |
|
||||
| `配置:业务:修订版` |没有 EX |每次配置写入时原子“INCR”;首次初始化时“SET 1 NX”| `business_config_revision` |
|
||||
| `配置:出站` |没有 EX | `set_outbound_config` | `get_outbound_config` |
|
||||
| `配置:启用` |没有 EX | `设置启用` | `已启用` |
|
||||
| `配置:admin_session` |前 900 | `put_admin_session` | `admin_session_valid`;由 `revoke_admin_session` 清除 |
|
||||
|
||||
笔记:
|
||||
|
||||
- `config:business:revision` 是一个**原子 Redis `INCR`**,而不是读取-修改-写入。每个
|
||||
承载请求的入口点调用“refresh_business_config”,这仅
|
||||
当远程修订超出缓存的本地值时重建工作程序 - 即
|
||||
Guard 可以防止陈旧或格式错误的快照变成重建循环。
|
||||
- `config:admin_session` 不携带超出写入时传递的文字 `EX 900` 的 TTL 元数据
|
||||
时间;处理程序在其响应正文中报告相同的窗口“expires_in: 900”
|
||||
在处理程序一侧。
|
||||
- `config:enabled` 被视为一个门;缺失或“错误”会导致业务处理中断。
|
||||
|
||||
### 1.2 AI consent
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `同意:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` | `ai_consent_until`;由 `clear_ai_consent` 清除 |
|
||||
|
||||
存储的值是**绝对 Unix 到期时间戳**,而不是持续时间;钥匙是“EX”
|
||||
具有相同的持续时间,因此密钥会自行删除。
|
||||
|
||||
触发词是精确的中文文字,**没有英文别名** - 应用程序内帮助文本
|
||||
本身强制执行(“worker.rs”中的应用内帮助文本):
|
||||
|
||||
|输入 | TTL |标签|
|
||||
|---|---|---|
|
||||
| `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | 3600 `1小时` |
|
||||
| `临时`、`一次`(子字符串)| 3600 | 3600 `临时1小时` |
|
||||
| `今天` | 86_400 | `今天` |
|
||||
| `7天` | 604_800 | `7天` |
|
||||
| `直到我取消`, `长期` (子串) | 31_536_000(365 天)| `直到截至(截至365天)` |
|
||||
| `/ai off`、`关闭 ai`、`取消授权`、`停止摘要` | — |撤销|
|
||||
|
||||
`/ai ...` 斜杠形式与整个消息完全匹配;中文短语是
|
||||
子字符串匹配消息中的任何位置(都在“parse_intent”、“worker.rs”内)。
|
||||
|
||||
`1小时` / `临时1小时` / `直到为止(截止365天)`是**类别标签,而不是触发词**:
|
||||
单独输入标签不会带来任何好处。同意永远不会自动续订。
|
||||
|
||||
相同的“parse_intent”(“worker.rs”,“Intent”的变体)路由“/search <关键词>”
|
||||
以及中文前缀“搜索”、“查找”、“搜索”(仅匹配前缀,绝不是子字符串)
|
||||
`意图::搜索`。搜索未授予同意并且不写入 Redis 密钥。
|
||||
|
||||
### 1.3 Delivery pipeline
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `stalwart:jmap`, `stalwart:telegram` |流式传输,无 EX | `State::enqueue` (一个 `XADD`),从 `telegram_webhook` 和 `jmap_push` 调用;通过 `claim_dedup_and_enqueue` (`worker.rs`) | 协调路径消费者组“stalwart-workers”、消费者“http-worker”上的“read_batch” |
|
||||
| `交付:飞行中:{stream}:{id}` |前 60 |在交付路径中使用“claim_dedup”构建并声明密钥 |完成后`release_dedup`
|
||||
| `交付:已提交:{stream}:{id}` |前 604_800 |在交付路径中使用“claim_dedup”构建并声明密钥 |发送前`dedup_exists`
|
||||
| `重试:{stream}:{id}` |前 86_400 | `retry_or_dlq` 的 Lua 脚本(同一键上的 `INCR` 然后 `EXPIRE`)|回收路径|
|
||||
| `坚定:jmap:dlq`,`坚定:telegram:dlq` |流式传输,无 EX |相同 Lua 脚本的“XADD”;名称从工作线程中的 `retry_or_dlq` 调用站点传入(使用 `max_attempts` 3) | **代码中没有任何内容读取它** |
|
||||
| `状态:jmap:自` |无(耐用)| `set_reconcile_state` |每次传递之前的`get_reconcile_state`;根据 §6.5 进行编码 |
|
||||
|
||||
没有 `delivery:pending:{stream}` 键 — “pending”指的是 Redis Streams
|
||||
待处理条目列表 (PEL),Redis 内部维护。
|
||||
上面的流名称很具体:“stalwart:jmap”和“stalwart:telegram”;
|
||||
其余键中的“{stream}”是这两个之一。
|
||||
该错误记录在“docs/retired.md”中。
|
||||
|
||||
每个事件至多交付一次:“delivery:inflight”密钥在 60 秒的窗口内领取
|
||||
出站发送前,“delivery:comfilled”将保证延长至发送后 7 天
|
||||
确认。
|
||||
|
||||
DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 /
|
||||
`stalwart:telegram:dlq` 也是 `XACK` 是源流中的消息,因此递增,DLQ
|
||||
追加和源确认是原子的,全部都在一个 Lua 脚本内。没有代码路径读取 DLQ
|
||||
返回 — 重播是操作员操作,而不是服务功能。
|
||||
|
||||
### 1.4 Idempotency and rate limits
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `dedup:tg:{update_id}` |前 86_400 |使用 `telegram_webhook` 中的 `claim_dedup` 构建并声明,在失败的入队时使用 `release_dedup` 释放 | — |
|
||||
| `dedup:jmap:{帐户}:{电子邮件}` |前 86_400 |使用“jmap_push”中的“claim_dedup”构建并声明;在协调路径上重建相同的密钥并使用“claim_dedup_and_enqueue”(“worker.rs”)|声明— |
|
||||
| `速率限制:推送验证:{订阅}` |前 30 |使用“register_push”中的“claim_dedup”构建并声明; “错误”声明是“429 Push_verify_rate_limited”分支 | — |
|
||||
| `状态:jmap:自` | **无 EX** | `set_reconcile_state`(没有 `EX` 的 `SET`)| `get_reconcile_state` |
|
||||
|
||||
`state:jmap:since` 是唯一一个在没有显式声明的情况下无限期存在的状态键
|
||||
删除,按设计:它是协调游标,过期将强制完全删除
|
||||
下次重新启动时重新设定基线。
|
||||
|
||||
### 1.5 Locks
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `锁定:协调` | 300 | 300 `reconcile` 中的 `acquire_lock` |心跳任务中的 `renew_lock`(90 秒) |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 360 | 360 `register_push` 中的 `acquire_lock` | `register_push` 每个退出路径上的 `release_lock`
|
||||
|
||||
**为什么注册锁是 360 秒而不是 300 秒。** 锁的寿命必须是最长的
|
||||
配置 JMAP 请求超时(300 秒),否则缓慢的注册可能会导致
|
||||
重复。来源评论:
|
||||
|
||||
> 锁的寿命必须超过配置的 300 秒最大 JMAP 请求超时时间;这可以防止
|
||||
> 由于接受重复项而创建缓慢。
|
||||
|
||||
### 1.6 Push subscription state
|
||||
|
||||
|关键| TTL |作家 |读者|
|
||||
|---|---|---|---|
|
||||
| `推送:订阅:{id}` | EX 300(呼叫者提供,最少 1)| `remember_push_subscription`,从 `register_push` 调用 | `push_subscription_verified` |
|
||||
| `推送:订阅:id` |没有 EX | `记住推送订阅 ID` |当前订阅查找 |
|
||||
| `推送:订阅:{id}:状态` | 900 / 300 / 86_400(最低 1 强制执行)| `set_push_subscription_status`;验证请求时为“pending”,成功时为“verified”,禁用时为“disabled” | **无** |
|
||||
| `推送:注册:{sha256(callback_url)}` |前 604_800 | `remember_push_subscription_for_callback`,从 `register_push` 调用 | `get_push_subscription_for_callback`;通过 `remove_push_subscription_for_callback` 删除 |
|
||||
| `push:orphan:{subscription_id}` |前 604_800 | `record_push_orphan` |孤儿扫荡|
|
||||
|
||||
**两个不同的键经常被混淆。在编辑之前请阅读本文。**
|
||||
|
||||
- `push:registration:{sha256(callback_url)}` 是从回调 URL 到
|
||||
订阅 ID**,TTL 7 天。它不是一把锁。
|
||||
- `lock:push-register:{sha256(callback_url)}` 是**单次航班注册锁**,
|
||||
TTL 360 秒。它不是映射。
|
||||
|
||||
它们使用相同的摘要,但是是具有不相关生命周期和用途的单独密钥。
|
||||
|
||||
**注释:`push:subscription:{id}:status` 设计为只写。**
|
||||
来源对“state.rs”有明确的注释:
|
||||
|
||||
> `push:subscription:{id}:status` 目前是只写的
|
||||
|
||||
它是一个用于“redis-cli”检查的操作/可观察性跟踪;它**不是**门
|
||||
并且没有任何内容读取它以获取授权。曾经写入的三个值是“pending”(900 s),
|
||||
“已验证”(300 秒)和“已禁用”(86400 秒)——请参阅上表了解其调用站点。
|
||||
不要根据它的存在来推断其正确性。
|
||||
|
||||
---
|
||||
|
||||
## 2. Error envelope
|
||||
|
||||
每个错误响应都使用一种包络形状:
|
||||
|
||||
```json
|
||||
{ "error": "<machine_code>", "request_id": "<id>" }
|
||||
```
|
||||
|
||||
`error` 是一个**普通的机器可读字符串**,而不是嵌套对象 - 没有 `message`
|
||||
场。 `request_id` 始终存在。
|
||||
|
||||
|状态 |代码|重试后 |
|
||||
|---|---|---|
|
||||
| 400 | `invalid_request`、`missing` | — |
|
||||
| 401 | 401 `未经授权` | — |
|
||||
| 403 | 403 `forbidden`、`chat_not_allowed`(仅限 `/debug/*`)| — |
|
||||
| 404 | 404 `push_subscription_not_found` | — |
|
||||
| 409 | 409 `冲突` | — |
|
||||
| 422 | 422 `无效配置` | — |
|
||||
| 429 | 429 `push_verify_rate_limited` | `30` |
|
||||
| 500 | 500 `内部错误` | — |
|
||||
| 502 | 502 `telegram_send_failed`(仅限`/debug/*`)| — |
|
||||
| 503 | 503 `service_unavailable`、`disabled`、`reconcile_retry`、`push_verify_failed`、`push_state_unavailable`、`push_destroy_failed` |可重试时为“30”,或始终用于推送注册/禁用失败 |
|
||||
|
||||
`Retry-After` 有两个发射点,都在 `notify.rs` 中,并且值始终是文字
|
||||
字符串“30”:
|
||||
|
||||
- `error_response` 仅当其 `retry` 标志为 true 时才设置它**。准备失败
|
||||
路径传递 `retry = true`,因此 `/ready` 被该规则覆盖,而不是被
|
||||
特殊情况。
|
||||
- `error_response_with_id` 设置它**无条件**。仅用于推送
|
||||
注册/禁用失败“push_state_unavailable”和“push_destroy_failed”,它们是
|
||||
总是“503”。
|
||||
|
||||
`GET /api/status` 是使用非信封正文报告设置状态的例外情况。
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend routes
|
||||
|
||||
所有 HTTP API 路由均在“router_with_worker_state_runtime_bootstrap”中注册。
|
||||
最后一个表行中的三个静态 SPA 路由是例外:它们位于
|
||||
`web::router()` (`src/web.rs`) 并通过 `.merge(web::router())` 合并到路由器中 —
|
||||
在设置模式路由器中,然后在生产路由器中再次。设置模式路由器安装
|
||||
这个表只有三个——“/healthz”、“/ready”和“/api/status”——没有业务或
|
||||
管理路线; §5.1 解释了为什么这是故意的。
|
||||
|
||||
|方法|路径|处理器|
|
||||
|---|---|---|
|
||||
|POST|`/webhook/tg`|`telegram_webhook`|
|
||||
|POST|`/push/jmap`|`jmap_push`|
|
||||
|POST|`/api/push/register`|`register_push`|
|
||||
|POST|`/api/telegram/register-webhook`|`register_telegram_webhook`|
|
||||
|POST|`/api/push/disable`|`disable_push`|
|
||||
|POST|`/reconcile`|`reconcile`|
|
||||
|POST|`/worker`|`worker`|
|
||||
|GET, PUT|`/api/config`|`get_config` / `put_config`|
|
||||
|GET, PUT|`/api/enabled`|`get_enabled` / `put_enabled`|
|
||||
|GET, PUT|`/api/business-config`|`get_business_config` / `put_business_config`|
|
||||
|POST|`/api/business-config/preflight`|`preflight_business_config`|
|
||||
|POST|`/api/bootstrap`|`bootstrap`|
|
||||
|POST|`/api/admin/session/revoke`|`revoke_admin_session`|
|
||||
|POST|`/api/admin/session`|`create_admin_session`|
|
||||
|GET|`/healthz`|`healthz`|
|
||||
|GET|`/ready`|`ready`|
|
||||
|GET|`/api/status`|`setup_status`|
|
||||
|GET|`/`、`/assets/config.js`、`/assets/styles.css`|`web::router()`|
|
||||
|
||||
远程调试表面(`debug_router`、`src/debug.rs`)仅在
|
||||
请求调试界面 - 命令行上的“--debug”或真实的“DEBUG_ENABLED”
|
||||
env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读入
|
||||
在构建任一路由器之前的`src/main.rs`)。否则这些路线都不存在并且
|
||||
请求落入 axum 的通用“404”,而不是 401。所有七个请求都需要
|
||||
`Authorization: Bearer <DEBUG_TOKEN>`,由 `debug_authorized` 检查,委托给
|
||||
生产“worker_authorized”,因此比较是恒定时间。
|
||||
|
||||
|方法|路径|处理程序 |
|
||||
|---|---|---|
|
||||
|获取 | `/调试/ping` | `debug_ping` |
|
||||
|获取 | `/调试/配置` | `调试配置` |
|
||||
|获取 | `/调试/redis` | `debug_redis` |
|
||||
|获取 | `/调试/jmap` | `debug_jmap` |
|
||||
|获取 | `/调试/电报` | `调试电报` |
|
||||
|获取 | `/调试/worker` | `debug_worker` |
|
||||
|发布 | `/调试/通知` | `调试_通知` |
|
||||
|
||||
响应约定:
|
||||
|
||||
- `GET /healthz` 无条件返回 **200**。它是一个活性探针,不能是
|
||||
用于决定是否路由流量。
|
||||
- 当配置、Redis 或上游探测器未准备好时,`GET /ready` 返回 **503**,
|
||||
并携带“Retry-After: 30”表示未准备好响应。它检查四件事:配置
|
||||
完整性(`setup_missing` 为空)、Redis 可访问性和两个上游探测器 —
|
||||
使用配置的基本凭据`GET {jmap_origin}/.well-known/jmap`,以及
|
||||
`获取 https://api.telegram.org/bot<token>/getMe`。两个探针共享“PROBE_TIMEOUT”= 3000ms
|
||||
并**并行**(`tokio::join!`),所以最坏的情况
|
||||
是单次超时,大约3s。成功时,正文是一份报告,其“jmap”/“telegram”字段
|
||||
是真实的探测结果。探针是简单的可重用函数(`probe_jmap_session`,
|
||||
`probe_telegram_get_me`) 也由远程调试路径使用,因此它们不能重复。
|
||||
JMAP 探针有意针对*规范化来源*进行身份验证:探测原始数据
|
||||
未经身份验证的会话 URL 将永远报告未就绪并使入口停止路由。
|
||||
- `GET /api/status`(**始终 200**)返回
|
||||
`{“ready”:<bool>,“mode”:“configured”|“configuration-setup”,“missing”:[...],“version”:“<build-fingerprint>”}`。
|
||||
“ready”为“false”,“missing”列出了缺少的必需密钥(“REDIS_URL”、
|
||||
`CONFIG_ENCRYPTION_KEY`)当变量不存在时 - 路由本身永远不会出错,所以它是
|
||||
可以安全进行民意调查。 `version` 是由 `build.rs` 烘焙的 `BUILD_VERSION` 字符串
|
||||
(`<git-sha-or-nogit>+<UTC 构建时间>`),这就是 SPA 页脚证明部署已落地的方式。
|
||||
- `/debug/*` 在一个地方返回 **503**:业务执行时`POST /debug/notify`
|
||||
配置未加载或没有出站客户端 — `service_unavailable`
|
||||
`重试时间:30`。三个探测端点反而报告失败
|
||||
**在 200 体内** (`{"ok":false,"detail":...}`),因此永远不会看到无法到达的上游
|
||||
就像停电一样。当聊天时,“POST /debug/notify”也会返回“403 chat_not_allowed”
|
||||
在*非空*允许列表之外,并且“502 telegram_send_failed”
|
||||
发送失败。
|
||||
- 静态资产由严格的CSP提供服务;参见§7。
|
||||
|
||||
---
|
||||
|
||||
## 4. Gateway vs backend route matrix
|
||||
|
||||
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
|
||||
> 18 个条目,以及 `ROUTE_METHODS` (`index.js`)
|
||||
> 它修复了每个路径的一个方法集。工作线程入口点是 `src/index.js`
|
||||
> (`牧马人.toml`); `src/lb.js` 执行转发和有界故障转移(`SAF-LB-PASSTHRU`,
|
||||
> `C-NO-LONG-CONN`)。
|
||||
|
||||
该门是**无条件且失败时关闭的**。工作线程没有读取任何配置开关
|
||||
all:`SAFE_ROUTES` 中缺少的路径返回 **404**,一个已注册的路径
|
||||
方法错误的路径返回**405**,并且缺少或无法解析
|
||||
后端池返回 **503** 而不是传递请求。
|
||||
|
||||
**由 Worker 转发(19):**
|
||||
|
||||
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
|
||||
`/api/business-config` · `/api/business-config/preflight` · `/api/admin/session` ·
|
||||
`/api/admin/session/revoke` ·
|
||||
`/api/enabled` ·
|
||||
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/telegram/register-webhook` · `/api/push/disable` · `/reconcile` ·
|
||||
`/worker` · `/ready` · `/healthz`
|
||||
|
||||
**已在后台注册但未转发(2):**
|
||||
|
||||
|路径|为什么网关上没有它 |
|
||||
|---|---|
|
||||
| `POST /api/bootstrap` |一次性信任引导;远离公共道路|
|
||||
| `/debug/*`(7 条路线)|选择加入远程调试界面(`SAF-DEBUG-GATE`); “SAFE_ROUTES”中不存在,因此**只能**通过直接与后端源通信来访问它 |
|
||||
|
||||
“GET, PUT /api/enabled”(“SAF-ENABLE-FLAG”终止开关)**被转发,因为
|
||||
管理 SPA 在 Worker URL 上提供服务,并从服务卡(`loadEnabled`
|
||||
读取它,切换开关写入它);两个电话都是
|
||||
admin-session Bearer-auth'd,因此暴露与已转发的相同
|
||||
`/api/admin/session` 对。
|
||||
|
||||
结果:剩余路由均不承载外部业务流量,因此没有第二个入口
|
||||
在后端实例之前需要。 SPA的首次启动流程仍然无法驱动
|
||||
通过worker的`/api/bootstrap` - 必须针对后端源执行引导程序
|
||||
直接,或者必须将引导路径添加到网关白名单中。
|
||||
|
||||
`POST /api/push/register`、`POST /api/telegram/register-webhook` 和 `POST /api/push/disable` **被转发。这些接口可以安全地
|
||||
proxy:回调 URL 由客户端在请求正文中提供,
|
||||
在写入任何内容之前验证为 URL,并且每个推送订阅记录都是
|
||||
写入共享 Redis 并从共享 Redis 读取(`lock:push-register:{sha256(url)}`、`get_push_subscription_for_callback`),
|
||||
因此工作人员选择哪个后端实例并不重要。推
|
||||
因此,注册不再需要访问特定的后端地址。操作员保存业务配置后,SPA 会通过受保护接口提交两个回调 URL。
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment variables
|
||||
|
||||
三个不同的层。它们不可互换。
|
||||
|
||||
### 5.1 Boot-time (2 required, 2 optional with defaults)
|
||||
|
||||
|变量|必填 |默认 |来源 |
|
||||
|---|---|---|---|
|
||||
| `REDIS_URL` |是的 | — |在启动时读取`src/main.rs` |
|
||||
| `CONFIG_ENCRYPTION_KEY` |是的 | — | `encryption_key_from_env` |
|
||||
| `端口` |没有| `8080` | `unwrap_or(8080_u16)`。监听者直接绑定它; `Config` 不携带端口字段 |
|
||||
| `DEBUG_ENABLED` |没有| — |真值为 `1`/`true`/`TRUE`/`True`/`yes`/`YES` (完全匹配);单独启用调试界面,与 `--debug` 配对作为替代方案 (`SAF-DEBUG-GATE`) |
|
||||
| `DEBUG_TOKEN` |没有| — |仅在通过“DEBUG_ENABLED”或“--debug”(“SAF-DEBUG-GATE”/“SAF-DEBUG-ORIGIN-ONLY”)请求调试表面后才有效
|
||||
|
||||
`RUN_MODE` **不再存在**:标识符与旧版本一起被删除
|
||||
`Config::from_env()` 环境解析器(在 `docs/retired.md` 中注册)。两个网络钩子
|
||||
和协调流量共享一个路由器,并且协调是一个独立的“POST /reconcile”
|
||||
端点,因此该变量从未改变任何运行时行为 - 现在无需设置任何内容。
|
||||
|
||||
**缺少必需的变量不会使进程崩溃。**它会记录警告并提供服务
|
||||
`router_configuration_setup`,仅挂载`/api/status`,
|
||||
静态 SPA 之上的“/ready”和“/healthz”。 §3 中的业务和管理路线不是
|
||||
完全注册:“admin_token”为空,因此“constant_time_eq”会拒绝
|
||||
每个候选人永远,并且“MemoryState”无法持久保存引导程序
|
||||
无论如何都要写。在此模式下发布到“/api/bootstrap”会产生“404”(路由不存在),而不是“401”
|
||||
其内容为“使用更好的凭据重试”。容器保持运行并回答状态
|
||||
在不做任何业务工作的情况下浮出水面。这是一种故意的失败关闭设置姿势,并且它
|
||||
是“容器正常但没有任何反应”的最可能的原因。
|
||||
|
||||
### 5.2 Redis-resident business configuration
|
||||
|
||||
SPA 通过 `GET /api/business-config` 读取并写入
|
||||
`PUT /api/business-config` (1秒内生效);第一次成功写入创建
|
||||
配置,稍后写热重载吧。 `GET` 返回四个键 — `configured`、`revision`、
|
||||
`values`、`secrets_present` — 保存 10 个非秘密字段以及每个字段一个存在布尔值
|
||||
秘密(`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 主体是 ** 部分
|
||||
patch** 仅替换其命名的字段并保留其余字段的存储值(`apply`
|
||||
在`config.rs`中);补丁中省略的秘密保留了存储的秘密,因此预填表格
|
||||
值可以安全地重新提交。显式提交的空字符串按原样存储并且实际上
|
||||
揭开秘密——“空白意味着不变”是客户端合同,由 SPA 强制执行
|
||||
在发送之前删除空白秘密字段,而不是由服务器删除。
|
||||
|
||||
增量语义仅在配置存在后才有效。没有任何存储,就没有
|
||||
要回退到的值,因此不完整的补丁会被拒绝,并显示 **422 `invalid_configuration`**
|
||||
— 第一次保存必须完成。针对后端原点的“POST /api/bootstrap”是
|
||||
一次性写入路径,从头开始创建配置以实现自动化。过程从来没有
|
||||
在正常操作中从环境中读取这些。
|
||||
|
||||
**坚持并报告,而不是坚持如果可连接。**验证是写入的唯一门,
|
||||
在“PUT”上,它针对 **合并** 配置运行,而不是提交的正文:补丁
|
||||
永远不会单独验证,并且完整存储的连线永远不会被丢弃(“apply”在
|
||||
`config.rs`)。 “validate_business_wire”首先运行,拒绝是真正的“422”。曾经
|
||||
合并的线路是有效的,它*始终*持续存在 - 合并的线路,而不是请求正文 - 并且
|
||||
然后才构建客户端并运行
|
||||
工人交换了。因此,失败的构建会返回 **200** 并显示“persisted: true”,
|
||||
`runtime_applied: false` 和一个包含 `{component,step,detail}` 对象的 `warnings` 数组,其中
|
||||
`component` 是 `jmap` 或 `llm`,`step` 是 `connect`、`account`、`config` 或 `build`,而不是
|
||||
比 503 更安全。保存配置并报告中断而不是隐藏。
|
||||
`runtime_applied` 是
|
||||
仅当提交重新加载时才为“true”。这是故意的:无法访问的 JMAP 一定不能
|
||||
将有效配置转变为静默数据丢失。 `PUT /api/business-config` 和
|
||||
`POST /api/bootstrap` 都遵循这个契约;在这两种情况下都会返回修订版
|
||||
`x-business-config-revision` 标头,只有 `PUT /api/business-config` 也会在
|
||||
响应正文,作为“修订版”(“u64”)。 SPA 将其显示在其状态行中并发送
|
||||
作为“revision”控制字段返回到请求正文中,后端将其与
|
||||
存储修订版本并在不匹配时回答“409 冲突”。引导程序的
|
||||
主体不携带“修订”字段,并且省略它的“PUT”主体保持最后写入获胜。
|
||||
|
||||
`POST /api/business-config/preflight` (`preflight_business_config`) 运行相同的
|
||||
验证和客户端根据提交的线路构建并返回每个组件的判决
|
||||
无需编写任何内容,也无需接触正在运行的工作人员:
|
||||
|
||||
```json
|
||||
{
|
||||
"persisted": false,
|
||||
"validation": { "ok": true, "errors": [] },
|
||||
"components": {
|
||||
"jmap": { "ok": false, "errors": [{ "component": "jmap", "step": "connect", "detail": "..." }] },
|
||||
"llm": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当验证已经失败时,“components”为“null”(没有必要从
|
||||
被拒绝的线),当“llm_enabled”/“llm_allow_net”为 false 时,“llm”为“null”
|
||||
而不是空洞地“好吧”。答案是“配置不好”的预检是
|
||||
**200**,因为这是呼叫者要求的答案。认证是一样的
|
||||
`config_authorized` 门作为写入路径;它故意**不**位于`DEBUG_TOKEN`后面。
|
||||
|
||||
### 5.3 No legacy environment path
|
||||
|
||||
`Config::from_env()` 及其助手(`required_secret`、`required_nonblank`、`env_bool`)是
|
||||
**已删除** — `config.rs` 根本不执行 `std::env` 读取。下面的名字是遗产
|
||||
env 表面并仅列出,以便过时的部署脚本可以被识别为过时的
|
||||
(在`docs/retired.md`中注册):
|
||||
|
||||
`RUN_MODE` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` · `LLM_ENABLED` · `LLM_ALLOW_NET` ·
|
||||
`LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` · `LLM_SUMMARY_TARGET_CHARS` · `BOT_TOKEN` ·
|
||||
`JMAP_SESSION_URL` · `JMAP_USERNAME` · `JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` ·
|
||||
`TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
|
||||
|
||||
`JMAP_*` 系列恰好是三个名称。在生产中,帐户 ID 作为
|
||||
`jmap_push` 上的 `accountId` 请求正文字段,并在客户端上保存为
|
||||
`account_id`;唯一剩下的环境读取这些
|
||||
四个名字在`#[ignore]`真实服务器冒烟测试中
|
||||
`src/domain/jmap/client.rs`,它不是生产配置界面的一部分。
|
||||
|
||||
单个前缀名称是“TELEGRAM_CHAT_ID”。任何其他记录为
|
||||
`TELEGRAM_BOT_TOKEN` 或类似的内容是文档错误,不是受支持的变量。
|
||||
|
||||
**信任根。** SPA 管理员凭据本身是“CONFIG_ENCRYPTION_KEY”:在启动时读取
|
||||
并保存为“admin_token”,通过以下方式在恒定时间内进行检查
|
||||
两者顶部的“worker_authorized”
|
||||
`POST /api/bootstrap` 和 `POST /api/admin/session`。它是
|
||||
仅与请求承载进行比较——从不回显、记录或存储。会议
|
||||
由 `/api/admin/session` 发出是一个新生成的随机 32 字节十六进制令牌
|
||||
(每次调用生成),绝不是凭证;仅其摘要保留在 Redis 中
|
||||
`配置:admin_session`。 `REDIS_URL` 是单独的 Redis 连接字符串:ACL 密码
|
||||
它(如果有)对 Redis 连接进行身份验证,并且不是任何 HTTP 的凭据
|
||||
端点。
|
||||
|
||||
### 5.4 Notification rendering
|
||||
|
||||
`send_notification` (`src/channel.rs`) 是唯一将通知渲染到
|
||||
Telegram 文本,它只渲染三个元数据行——不是正文,也不是
|
||||
法学硕士摘要:
|
||||
|
||||
```text
|
||||
From: Zhang San <zhang@example.com>
|
||||
Subject: Q4 budget
|
||||
Received: 2026-09-28 09:15
|
||||
```
|
||||
|
||||
`From:` 是标准显示形式的 JMAP `EmailAddress`:`Name <address>`
|
||||
发件人带有显示名称,否则为裸地址。 “发件人:”和“主题:”
|
||||
仅当 JMAP 未返回时才回退到文字(“未知”/“(无主题)”)
|
||||
财产。
|
||||
|
||||
“已收到:”在通知时呈现,而不是存储为文本。服务器保留
|
||||
JMAP `receivedAt` 作为 Unix 秒并将其转换为配置中的挂钟时间
|
||||
使用 `%Y-%m-%d %H:%M` 的时区 — 24 小时制,分钟精度,无 AM/PM,无秒。
|
||||
当 `receivedAt` 不存在,或超出区域的可表示即时范围时,
|
||||
显示文字“未知”而不是剪辑的时间戳。
|
||||
|
||||
时区是一个业务配置字段,`timezone` (`REQ-TIMEZONE-DISPLAY`):
|
||||
IANA 标识符,默认“亚洲/上海”。只接受 16 个区域,每一个区域
|
||||
没有夏令时转换 - `Etc/UTC`、`非洲/开罗`、`欧洲/伊斯坦布尔`、
|
||||
`非洲/内罗毕`、`亚洲/迪拜`、`亚洲/卡拉奇`、`亚洲/加尔各答`、`亚洲/曼谷`、
|
||||
`亚洲/胡志明市`、`亚洲/上海`、`亚洲/香港`、`亚洲/台北`、`亚洲/新加坡`、
|
||||
“亚洲/马尼拉”、“亚洲/东京”、“亚洲/首尔”。任何其他标识符都会被拒绝,并显示 **422**
|
||||
而不是默默地回到猜测。这是一个故意的限制:离线
|
||||
build 没有 IANA tz 数据库(“chrono-tz”不可用),因此每个区域解析为
|
||||
固定偏移并且从不跟踪转换。十六个人中没有一个需要一个,这就是为什么
|
||||
‘亚洲/上海’可以是默认值。与其他业务配置一样,它是经过编辑的
|
||||
在 SPA 中并热应用,无需重新启动。
|
||||
|
||||
---
|
||||
|
||||
## 6. Outbound and runtime parameters
|
||||
|
||||
### 6.1 Outbound
|
||||
|
||||
|参数|默认 |范围/上限|来源 |
|
||||
|---|---|---|---|
|
||||
| `jmap_timeout_ms` | 15_000 | 15_000 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
|
||||
| `telegram_timeout_ms` | 10_000 | 10_000 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
|
||||
| `llm_timeout_ms` | 30_000 | 100..=300_000 | 100..=300_000默认在`state.rs`中;在写入路径中验证|
|
||||
| `最大重试次数` | 3 |硬顶 5 |默认在`state.rs`中;在写入路径中被拒绝;重新固定在`channel.rs` |
|
||||
|
||||
**没有** `LLM_MAX_RETRIES` 环境变量;重试计数位于
|
||||
`config:outbound` 并且无论写入什么内容都以 5 为界。
|
||||
|
||||
### 6.2 Reconcile budgets
|
||||
|
||||
|恒定|价值|来源 |
|
||||
|---|---|---|
|
||||
| `BASELINE_PAGE_SIZE` | 100 | 100 `worker.rs` |
|
||||
| `BASELINE_MAX_PAGES` | 100 | 100 `worker.rs` |
|
||||
| `BASELINE_MAX_EMAILS` | 10_000 | 10_000 `worker.rs` |
|
||||
| `RECONCILE_MAX_PAGES` | 100 | 100 `worker.rs` |
|
||||
| `协调预算` | 20 秒 | `worker.rs` |
|
||||
| `CHANGE_WINDOW_CAP` | 4_096 | 4_096 `worker.rs` |
|
||||
| `协调初始更改` | 100 | 100 `notify.rs` |
|
||||
|锁定TTL/心跳| 300 秒 / 90 秒 | `reconcile` 及其心跳任务 |
|
||||
|
||||
`initial_changes` 是一个函数参数,而不是一个常量:它只调整
|
||||
第一个“/changes”调用。重播阶段之后可能会扩大窗口,最多
|
||||
`CHANGE_WINDOW_CAP`,在光标前进之前。
|
||||
|
||||
### 6.3 Idle threshold
|
||||
|
||||
XAUTOCLAIM 的空闲阈值源自实时出站配置:
|
||||
|
||||
```
|
||||
count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2
|
||||
```
|
||||
|
||||
低于传统 300 秒单事件上限,上限为 6 小时。地板也是
|
||||
当无法读取配置时回退,因此
|
||||
Redis 打嗝永远不会折叠窗口。仅在多实例上重复
|
||||
赌注,永不损失。
|
||||
|
||||
### 6.4 Search limits
|
||||
|
||||
|恒定|价值|来源 |
|
||||
|---|---|---|
|
||||
| `SEARCH_LIMIT` | 10 | 10 `worker.rs` |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 个字符 | `worker.rs` |
|
||||
| `SEARCH_PREVIEW_MAX` | 160 个字符 | `worker.rs` |
|
||||
|
||||
### 6.5 Reconcile cursor
|
||||
|
||||
`/reconcile` 保留一个游标,`state:jmap:since`,并且它是双峰的:
|
||||
|
||||
- `baseline:{hex-encoded-state}:{position}` — 位置行走正在飞行中。的
|
||||
walk 按位置 (`list_emails_page`) 和 `/changes` 枚举集合
|
||||
尚未重播。
|
||||
- 裸状态字符串 — 步行已完成;这是恢复的令牌
|
||||
“电子邮件/更改”来自。
|
||||
|
||||
这两种模式是“worker.rs”中显式的“ReconcileCursor”变体,而不是
|
||||
保留位置值。页上限(`BASELINE_MAX_PAGES`)是一个硬停止
|
||||
步行:如果在那里剪切步行,则光标将保持**作为步行**,从不
|
||||
作为重播,因为重播模式断言列表已用完并正在重播
|
||||
来自半列出状态的“/changes”将跳过仍然存在的内容。
|
||||
|
||||
有两个令牌类别正在发挥作用,并且它们不可互换:
|
||||
|
||||
- `Session.state`(RFC 8620 §2.1)是每个会话创建的,并且**不是**
|
||||
“电子邮件”-收集令牌。新的或重新设定基线的游标带有它,因为
|
||||
jmap-client `Session` 类型不公开每个集合的状态。
|
||||
- “Changes”响应中的“newState”**是**一个集合令牌。
|
||||
|
||||
因此,严格遵守的服务器可能会拒绝第一个“/changes”调用
|
||||
重新设定基线后出现 `sinceState` 错误。 `replay_changes` 通过以下方式吸收它
|
||||
重新基线到一个新的令牌并返回,因此不匹配只能咬住
|
||||
一次调用和下一次传递重新开始步行; 24小时重复数据删除密钥
|
||||
(`ttl::DEDUP_JMAP_SECONDS`) 将生成的重播限制为最多一个重复项。
|
||||
|
||||
`Email/changes` 还返回 `destroyed` 和 `oldState`。两者均被丢弃
|
||||
`EmailChanges`:已删除的电子邮件没有任何内容可供查找,因此它不会驱动
|
||||
通知并且不移动光标,“oldState”回显
|
||||
服务器接受的“sinceState”,调用者已经持有该状态。保留任一
|
||||
意味着存储一个无人读取的值。
|
||||
|
||||
---
|
||||
|
||||
## 7. Static assets and response headers
|
||||
|
||||
从“src/web.rs”提供服务。
|
||||
|
||||
- 仅三个路由:`GET /`、`GET /assets/config.js`、`GET /assets/styles.css`。
|
||||
其他一切都返回 404。
|
||||
- 资产在编译时通过“include_str!”嵌入。没有
|
||||
运行时的文件系统访问。
|
||||
- 每个响应都带有 `Content-Type`、`Cache-Control: no-store`、
|
||||
`X-Content-Type-Options: nosniff` 和 `Referrer-Policy: no-referrer`。
|
||||
- CSP **仅应用于 HTML 文档**:插入到内部
|
||||
一个 `if html` 块:
|
||||
|
||||
```
|
||||
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self';
|
||||
form-action 'self'; base-uri 'none'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
- `X-Frame-Options: DENY` 与 CSP 一起设置,仅在 HTML 响应上。
|
||||
|
||||
`default-src 'none'` 故意比 `'self'` 更严格:页面默认关闭
|
||||
并且只有`script-src`、`style-src`和`connect-src`重新打开同源通道。
|
||||
|
||||
---
|
||||
|
||||
## 8. Not implemented
|
||||
|
||||
在此记录,以便其他地方的引用不会被误认为是已发布的功能。
|
||||
|
||||
-“/search”**已**实现(“bfe0fd8”)。适配器:“worker.rs”中的“Intent::Search”和
|
||||
在`src/domain/jmap.rs`中,由`src/domain/jmap/client.rs`中的`search_emails`支持。
|
||||
什么是“不可能”的:**身体层面**片段。 jmap-client `0.4.2` 仅公开
|
||||
来自“SearchSnippet/get”的“emailId”/“subject”/“preview”,其“Filter”类型没有比较器语法,因此
|
||||
每个部位的身体高光无法通过锁定的板条箱建模。搜索降级为
|
||||
当不支持片段时,“主题”/“预览”突出显示以及纯 ID 列表。设计
|
||||
请注意“docs/design.md”第 5.7 节。
|
||||
- Cloudflare Worker 不包含 Rust:“cloudflare-worker/src/”仅包含“index.js”,
|
||||
`lb.js`、`backends.js` 和 `health.js`。在“cloudflare-worker/”下写入的任何“*.rs”路径
|
||||
是文档错误,而不是源文件。
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
外部依赖项,全部可达并在验证时返回 HTTP 200:
|
||||
|
||||
- axum — https://docs.rs/axum
|
||||
- redis(Rust 箱,0.27)- https://docs.rs/redis/0.27/redis/
|
||||
- reqwest(Rust 板条箱,0.13)- https://docs.rs/reqwest/0.13/reqwest/
|
||||
- jmap-client (0.4.2) — https://docs.rs/jmap-client/0.4.2
|
||||
- JMAP 规范 — https://jmap.io/spec/
|
||||
- JMAP RFC 8620 — https://datatracker.ietf.org/doc/rfc8620/
|
||||
- Redis 流 — https://redis.io/docs/latest/develop/data-types/streams/
|
||||
- Redis 持久化 — https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
|
||||
- Cloudflare Workers — https://developers.cloudflare.com/workers/
|
||||
- Cloudflare Workers 运行时 API — https://developers.cloudflare.com/workers/runtime-apis/
|
||||
- RFC 8030(网络推送)— https://www.rfc-editor.org/rfc/rfc8030
|
||||
- RFC 7231 §7.2.3 — https://www.rfc-editor.org/rfc/rfc7231#section-7.2.3
|
||||
- Dockerfile 参考 — https://docs.docker.com/reference/dockerfile/
|
||||
- rust Docker 镜像 — https://hub.docker.com/_/rust
|
||||
- Uptime Kuma — https://uptime.kuma.pet/
|
||||
- OWASP 日志记录备忘单 — https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||
|
||||
未链接,因为它们未解析为文档:`crates.io/crates/jmap-client`
|
||||
(404) 和 `platform.openai.com` 文档路径 (403)。该项目描述了法学硕士
|
||||
依赖项与 OpenAI 兼容,并通过“reqwest”记录它。
|
||||
|
||||
## 9. Deployment platform details
|
||||
|
||||
面向操作员的序列位于 [`deployment.md`](deployment.zh-CN.md) 中。本节保留
|
||||
配置或诊断时有用的特定于平台的行为和设置
|
||||
部署。
|
||||
|
||||
### 9.1 Backend deployment paths
|
||||
|
||||
**HostStack 生产路径。** 存储库的 `hoststack.yaml` 是生产路径
|
||||
HostStack 的本机 Rust 运行时的配置。它运行 `cargo fetch --locked`,构建
|
||||
使用“cargo build --release --locked”,启动“./target/release/message-weave”,并使用
|
||||
`/healthz` 表示活跃度。将`REDIS_URL`和`CONFIG_ENCRYPTION_KEY`设置为加密服务
|
||||
秘密。 YAML 中声明的服务命令会覆盖存储的仪表板命令;去除
|
||||
该文件可以恢复HostStack的默认`./target/release/app`,这不是这个包的
|
||||
二进制。保持 YAML 签入状态,除非所有仪表板命令均已更正并且
|
||||
替换部署已得到验证。
|
||||
|
||||
HostStack路径不执行存储库Dockerfile。它的建造者和运行者是
|
||||
由平台管理。 Dockerfile 适用于本地容器运行和基于 Docker 的主机。
|
||||
|
||||
**Docker 镜像路径。** 根 Dockerfile 有两个阶段:
|
||||
|
||||
1. `rust:1-slim-bookworm` 使用锁定的 Cargo 依赖项构建发布二进制文件。
|
||||
2. `debian:bookworm-slim` 接收二进制文件、CA 证书、`tini` 和只读
|
||||
诊断工具(“curl”、“procps”、“iproute2”、“jq”和“netcat-openbsd”)。它创造了
|
||||
并以“messageweave”系统用户身份运行,设置“PORT=8080”,仅公开该端口,并且
|
||||
通过`tini`启动服务。
|
||||
|
||||
该映像没有 Redis 进程、数据库或持久数据卷。运行时配置是
|
||||
由主机注入;秘密不会进入构建参数或图像层。 Docker 平台
|
||||
运行状况检查应调用 [`deployment.md`](deployment.zh-CN.md) 中描述的 HTTP 端点。
|
||||
|
||||
### 9.2 Backend configuration and secrets
|
||||
|
||||
该进程在启动时仅读取“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。可选的
|
||||
远程调试界面使用独立的“DEBUG_ENABLED”开关和“DEBUG_TOKEN”;离开
|
||||
除非有意启用远程诊断,否则两者在生产中均未设置。完全启动
|
||||
变量语义和 Redis 业务配置字段位于 [§5](#5-environment-variables) 中。
|
||||
|
||||
Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 Redis 中,
|
||||
不是进程环境变量。 [§5.2](#52-redis-resident-business-configuration)中的字段列表和验证规则
|
||||
是权威的。成功的初始保存可以使用一次性“/api/bootstrap”端点
|
||||
在后端原点;该路由不通过 Worker 公开。后续编辑使用
|
||||
受保护的配置 API。
|
||||
|
||||
### 9.3 Callback registration and scheduled work
|
||||
|
||||
Telegram 的“setWebhook”请求必须使用配置的 Webhook 密钥并包含“message”
|
||||
在“允许的更新”中。使用“getWebhookInfo”检查生成的 URL;电报不回
|
||||
该回应中的秘密。旋转机密后重新运行“setWebhook”。
|
||||
|
||||
使用“POST /api/push/register”和 HTTPS“callback_url”注册 Stalwart 推送。后端
|
||||
创建订阅并完成 Stalwart 的验证回调。重复同样的事情
|
||||
回调 URL 是幂等的。使用带有该 URL 的“POST /api/push/disable”来删除它。
|
||||
|
||||
后端没有内部调度程序。 [`scripts/cron-drain.sh`](../scripts/cron-drain.sh)
|
||||
调用“/reconcile”(当设置“MW_RECONCILE_TOKEN”时),然后调用“/worker”。配置
|
||||
外部中的“MW_APP_URL”、“MW_WORKER_TOKEN”和可选的“MW_RECONCILE_TOKEN”
|
||||
调度程序的秘密存储。当操作员需要时`MW_WORKER_URL`可以覆盖默认值
|
||||
直接调用后端源。 `--once` 用于 cron; `--loop` 用于托管进程。
|
||||
该脚本还提供了“--diagnose”和“--test-notify”用于故障排除。均成功
|
||||
漏极端点返回“204”,响应正文为空; `/reconcile` 可以返回 `409` 而
|
||||
另一个协调拥有该锁。
|
||||
|
||||
### 9.4 Health and release checks
|
||||
|
||||
- `GET /healthz` 是后端活跃度。它并不能证明Redis或上游服务已经准备好。
|
||||
- `GET /ready` 检查配置、Redis、JMAP 会话和 Telegram 的 `getMe` 端点。
|
||||
当依赖项不可用时,它会返回“503”和“Retry-After”。上游检查
|
||||
需要从后端进行出站 HTTPS 访问。
|
||||
- `GET /api/status` 报告启动准备情况和缺少所需的启动变量名称。
|
||||
- `GET /healthz-worker` 由 Worker 生成。检查“available >= 1”和“version”;
|
||||
当原始配置丢失时,HTTP 200 本身就意味着“无后端”。
|
||||
|
||||
对于后端代码门,使用中锁定的命令和容器环境
|
||||
[`charter.md`](charter.zh-CN.md)。文档门必须在主机上运行,因此它
|
||||
路径审计可以读取Git历史记录。
|
||||
|
||||
### 9.5 Cloudflare Worker and Dashboard
|
||||
|
||||
Worker 是可选的 HTTPS 网关。后端来源必须是 HTTPS 字符串,不带任何内容
|
||||
路径、查询、片段或嵌入凭据。目前公开的来源清单和
|
||||
“LB_VERSION”在“[vars]”下的“cloudflare-worker/wrangler.toml”中声明。移动
|
||||
仅当其值是私有时,“BACKEND_ORIGINS_JSON”才为加密秘密;从不存储
|
||||
该列表中的凭据。 Worker本身不持有Telegram、JMAP、Redis或后端
|
||||
商业凭证。
|
||||
|
||||
|设置|仪表板价值 |笔记|
|
||||
|---|---|---|
|
||||
|根目录 | `cloudflare-worker` |存储库根包含 Rust 服务,而不是 Worker 配置。 |
|
||||
|应用名称 | `messageweave-lb` |匹配“wrangler.toml”中的“name”。 |
|
||||
|构建命令 |留空 | Wrangler 在部署期间捆绑 Worker;该字段不是测试命令。 |
|
||||
|部署命令| `npx 牧马人部署` |将此命令保留在所需的部署字段中。 |
|
||||
|预览命令 | `npx 牧马人开发 --ip 0.0.0.0 --端口 8787` |在启用预览版本时使用; “wrangler Preview”不是有效命令。 |
|
||||
|
||||
在仪表板中,在 **Settings → Variables & Secrets → Add 下添加 Worker 调整值
|
||||
变量**。默认值为“LB_REQUEST_TIMEOUT_MS=10000”、“LB_MAX_ATTEMPTS=2”、
|
||||
`LB_RECONCILE_TIMEOUT_MS=320000`、`LB_WORKER_TIMEOUT_MS=300000` 和
|
||||
`LB_HEALTH_TTL_MS=30000`。这些是可调整的变量,而不是秘密。 `LB_VERSION` 属于
|
||||
在“[vars]”中,以便部署的版本在源代码管理中保持可见。
|
||||
|
||||
如果私有来源列表必须存储在仪表板中,请使用**加密**并添加
|
||||
**设置→变量和秘密**下的`BACKEND_ORIGINS_JSON`;删除其“[vars]”条目。
|
||||
对加密变量的更改无需重建代码即可生效。仅预览环境
|
||||
接收为该环境配置的机密;可以开始没有原始列表的预览
|
||||
但无法验证后端转发。
|
||||
|
||||
对于 Git 集成,将选定的提交推送到连接到的 GitHub 存储库
|
||||
设置 `cloudflare-worker` 根目录后的 Cloudflare。构建命令可以是
|
||||
空;单独运行“npm test”作为 Worker 代码检查。部署后,请求
|
||||
`/healthz-worker`,确认 `available` 至少为 1,并检查 `version` 是否匹配
|
||||
`LB_VERSION`。 HTTP 200 的“status: no-backends”表示 Worker 没有可用的源列表;
|
||||
HTTP 503 的“status: down”意味着所有配置的后端都未通过健康检查。
|
||||
224
docs/retired.md
224
docs/retired.md
|
|
@ -1,159 +1,161 @@
|
|||
# 废弃与未采用路线(Retired Routes)
|
||||
# Abandoned and unused routes (Retired Routes)
|
||||
|
||||
> 本文只记录**三类**东西:①评估过但没采纳的路线;②真实存在过、后来被删掉的文档;③曾经写进文档、实际上从未存在或从未实现的名字。
|
||||
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
|
||||
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/opengaps.md`;当前设计见 `docs/design.md`。
|
||||
> 本文**没有行动项**。若某条变成要做的事,去 `docs/opengaps.md` 登记,本文条目保留为决策依据。
|
||||
> 本文**不引用任何不存在的路径**:凡路径,一律指向仓库中真实存在的文件;曾被写进文档但从未落地的路径只以文字描述其形态、不逐字复述,以免又被抄回正文或被人当成真实文件去查阅。
|
||||
> 验证基线:`b2dbe7c`。
|
||||
> [中文版本 / Chinese version → retired.zh-CN.md](retired.zh-CN.md)
|
||||
|
||||
> This article only records **three categories** of things: ① routes that were evaluated but not adopted; ② documents that actually existed but were later deleted; ③ names that were once written into documents but never actually existed or were never implemented.
|
||||
> The purpose is to retain the cause and prevent recurrence** - when you see these names in the future, you can immediately know what to use.
|
||||
> See `docs/reference.md` for the current real implementation and verifiable facts; `docs/opengaps.md` for gaps and blocking; see `docs/design.md` for the current design.
|
||||
> This article has **no action items**. If something becomes something to do, go to `docs/opengaps.md` to register, and the entries in this article will be retained as a basis for decision-making.
|
||||
> This article **does not cite any non-existent paths**: all paths point to files that actually exist in the warehouse; paths that have been written into documents but have never been implemented will only describe their form in text and will not be repeated verbatim to avoid being copied back into the text or viewed as real files.
|
||||
> Verify baseline: `b2dbe7c`.
|
||||
|
||||
---
|
||||
|
||||
## 1. 未采用的路线
|
||||
## 1. Route not taken
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
| Entry | Type | Reason | Alternative or Current Status |
|
||||
|---|---|---|---|
|
||||
| `teloxide`(Telegram Bot 框架) | 未采用 | Telegram 只需少量 API 调用,不引入重框架(dptree / 会话中间件),减少依赖面与抽象层;`reqwest` 已按 `ARCH-DEPS-STAGE4` 引入 | `src/channel.rs` 用 `reqwest` 自研实现 |
|
||||
| teloxide 计划 feature 集(`macros` / `rustls` / `redis-session` / `throttle` / `webhooks-axum`) | 未实施 | 随 teloxide 未采用而失效 | — |
|
||||
| `teloxide-core` 降级方案 | 未采用 | 降级前提是「用 teloxide 太重」;既然整体不引入,降级前提不成立 | — |
|
||||
| `grammers` / 旧 `telegram-bot` crate | 未采用 | 直接封 Bot API + 自研即可满足需求 | `src/channel.rs` |
|
||||
| `BotError::Telegram(#[from] teloxide::errors::RequestError)` | 目标形态变体,未落地 | 依附 teloxide,随其废弃 | 无 |
|
||||
| `BotError::Jmap` / `Storage` / `RateLimited` / `Unauthorized` / `Llm` | 目标形态变体,未落地 | 当时为统一承接 JMAP/Redis/限流/授权/LLM 五类错误而设计;现已由各模块自行处理 | 当前 `BotError` 仅 `Config` / `Io` / `Json` / `State` 四变体(`src/error.rs` 全文) |
|
||||
| Telegram 服务端 30 msg/s 限流桶 | 目标设计,未实现 | 无真实 Bot 压测数据,不预设实现;服务端分桶由 TG API 自身承担,bot 侧只做退避 | 429 专用分支**已实现**(`src/channel.rs`:解析响应体 `parameters.retry_after` 并钳 ≤60s,优先用服务器指定值否则指数退避),因此本行不构成缺口;服务端限流桶的当前处置仍是 `max_retries`(默认 3,硬上限 5)通用重试 |
|
||||
| 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 |
|
||||
| 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 |
|
||||
| teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
|
||||
| docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | `src/main.rs` 无 CLI 子命令解析,该子命令不存在,照抄必失败(早期还叠加了镜像内无 `curl` 的问题,现已内置 `curl`) | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 |
|
||||
| `teloxide` (Telegram Bot framework) | Not adopted | Telegram requires only a few API calls, does not introduce heavy frameworks (dptree/session middleware), and reduces dependencies and abstraction layers; `reqwest` has been introduced according to `ARCH-DEPS-STAGE4` | `src/channel.rs` is self-developed and implemented with `reqwest` |
|
||||
| teloxide planned feature set (`macros` / `rustls` / `redis-session` / `throttle` / `webhooks-axum`) | Not implemented | Dead when teloxide is not adopted | — |
|
||||
| `teloxide-core` downgrade plan | Not adopted | The premise for downgrading is "the use of teloxide is too heavy"; since the whole is not introduced, the premise for downgrading is not valid | — |
|
||||
| `grammers` / old `telegram-bot` crate | Not adopted | Directly seal the Bot API + self-research to meet the needs | `src/channel.rs` |
|
||||
| `BotError::Telegram(#[from] teloxide::errors::RequestError)` | Target form variant, not landed | Attached to teloxide, discarded with it | None |
|
||||
| `BotError::Jmap` / `Storage` / `RateLimited` / `Unauthorized` / `Llm` | Target form variant, not yet implemented | It was designed to uniformly handle the five types of errors of JMAP/Redis/current limiting/authorization/LLM; now each module handles it by itself | Currently `BotError` only has `Config` / `Io` / `Json` / `State` Four variants (full text of `src/error.rs`) |
|
||||
| Telegram server 30 msg/s current limit bucket | Target design, not implemented | No real Bot stress test data, no preset implementation; server bucketing is undertaken by TG API itself, and the bot side only performs backoff | 429 dedicated branch **implemented** (`src/channel.rs`: parse the response body `parameters.retry_after` and clamp ≤60s, the server-specified value will be used first, otherwise exponential backoff), so this line does not constitute a gap; the current processing of the server-side current limit bucket is still `max_retries` (default 3, hard upper limit 5) Universal retry |
|
||||
| Multi-step dialogue FSM (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | Target design, not implemented | Current AI authorization only needs a Boolean + expiration time, multi-step is over-design | Redis TTL authorization state, key and TTL, see the AI authorization state section of `docs/reference.md` |
|
||||
| Split each layer of domain / channel / notification / util into independent directories (sub-module split plan) | Target form directory, not implemented | The amount of code has not reached the scale that needs to be split | For the actual structure, see the project structure section of `docs/design.md`: the domain layer is `src/domain.rs` + `src/domain/jmap/` (only `client.rs`); LLM is in `src/ai.rs`; notifications are in `src/notify.rs`; encryption and tool logic are inline in `src/state.rs` / `src/config.rs`, there is no independent util layer and no integration test directory |
|
||||
| Teloxide style outbound message delivery integration test | Target form test, not yet implemented | Dependent on teloxide test mode | Unit test of `src/channel.rs` (`#[test]`) |
|
||||
| docker-compose `healthcheck` example (`message-weave health --addr ...`) | Deleted example | `src/main.rs` has no CLI subcommand resolution, the subcommand does not exist, and copying will fail (in the early days, there was also a problem of not having `curl` in the image, and now `curl` is built in) | `/ready` is detected by the platform ingress; for instructions, see `docs/deployment.md` Readiness Detection section |
|
||||
|
||||
| SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) |
|
||||
| Redis ACL password for SPA admin credentials = `REDIS_URL` | Route not taken | Obfuscating infrastructure credentials with UI admin password; `.is_empty()` guard for `bootstrap_token` when Redis has no ACL password (TLS-only managed Redis) makes SPA permanent 401 | Use instead `CONFIG_ENCRYPTION_KEY` (32-byte high-entropy hex required for startup, constant time comparison) |
|
||||
|
||||
### 1.1 teloxide 候选对比(评估记录)
|
||||
### 1.1 Teloxide candidate comparison (evaluation record)
|
||||
|
||||
下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。
|
||||
The following table and the following reasons are **original evaluation conclusions, and retaining them is the basis for decision-making, not the current technical facts** - "Active, latest 0.17, large download volume" and other framework attributes have not been reviewed in this round and do not represent the current version status of these crates.
|
||||
|
||||
| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 |
|
||||
| Framework | crate | maintenance status | features | fitness | conclusion |
|
||||
|---|---|---|---|---|---|
|
||||
| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、webhooks+webhooks-axum、Redis 会话存储、throttle、macros、tracing、rustls | ★★★★★ | **首选** |
|
||||
| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 |
|
||||
| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 |
|
||||
| **teloxide** | `teloxide` | Active, latest 0.17, large downloads | dptree distribution, conversational FSM, webhooks+webhooks-axum, Redis session storage, throttle, macros, tracing, rustls | ★★★★★ | **Preferred** |
|
||||
| grammers | `grammers` / `grammerslib` | General maintenance | MTProto (not Bot API), no Telegram Bot Token required | ★★ | Only if Bot API is not available |
|
||||
| telegram-bot (old) | `telegram-bot` | Basically discontinued | reqwest + futures | ★ | Not recommended |
|
||||
|
||||
**当初倾向 teloxide 的 6 条理由**:
|
||||
1. 与 `jmap-client` 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
|
||||
2. 内建 Dispatcher + `UpdateKind` 枚举匹配命令,与命令路由天然契合。
|
||||
3. 支持 `webhooks-axum`(生产 Webhook 形态;不使用长轮询 `NG-LONG-POLLING`)与 Redis 会话存储(记住当前文件夹/分页游标,不用 SQLite)。
|
||||
4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。
|
||||
5. `tracing` feature 与本项目观测性统一。
|
||||
6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。
|
||||
**6 reasons why I originally preferred teloxide**:
|
||||
1. It is the same tokio + reqwest ecosystem as `jmap-client`, and the runtime and TLS stack (rustls) can be reused.
|
||||
2. Built-in Dispatcher + `UpdateKind` enumeration matching commands, which is naturally compatible with command routing.
|
||||
3. Support `webhooks-axum` (production Webhook form; do not use long polling `NG-LONG-POLLING`) and Redis session storage (remember the current folder/pagination cursor, without SQLite).
|
||||
4. The `throttle` feature naturally fits Telegram’s 30 msg/s rate limit.
|
||||
5. The `tracing` feature is consistent with the observation of this project.
|
||||
6. The `macros` feature can use `#[teloxide::command]` to automatically parse command parameters and reduce boilerplate.
|
||||
|
||||
**当时的 feature 集计划**(阶段2 引入时):`macros`、`redis-session`、`throttle`、`tracing`,按需启用 `webhooks-axum`;TLS 侧 `rustls` 与 `rustls-native-roots` **按需二选一**(后者为前者补 OS 根证书,本机测试方便但生产多此一举)。
|
||||
**Feature set plan at that time** (when introduced in Phase 2): `macros`, `redis-session`, `throttle`, `tracing`, enable `webhooks-axum` on demand; `rustls` and `rustls-native-roots` on the TLS side **Choose one of the two on demand** (the latter supplements the OS root certificate for the former, which is convenient for local testing but unnecessary for production).
|
||||
|
||||
**替代/降级**:若 teloxide 升级或破坏性改动,可退到更薄的 `teloxide-core`(保留核心与 types,去掉 dispatcher 抽象);若需多账户高吞吐,用 `webhooks-axum` + 共享 `axum::Router`。以上两条均随 teloxide 未采用而不再成立。
|
||||
**Replacement/Downgrade**: If teloxide is upgraded or destructively changed, you can retreat to the thinner `teloxide-core` (retaining the core and types, removing the dispatcher abstraction); if you need multi-account high throughput, use `webhooks-axum` + shared `axum::Router`. Both of the above are no longer valid when teloxide is not adopted.
|
||||
|
||||
### 1.2 对话 FSM 状态转移表(目标设计,未落地)
|
||||
### 1.2 Dialogue FSM state transition table (target design, not yet implemented)
|
||||
|
||||
当前没有 FSM:`src/worker.rs` 的 `parse_intent` 直接解析为 `Intent`,AI 授权是 Redis 里的一个布尔加过期时间。曾设计过下面 5 个状态:
|
||||
There is currently no FSM: `parse_intent` of `src/worker.rs` is directly parsed into `Intent`, and AI authorization is a Boolean expiration time in Redis. The following 5 states have been designed:
|
||||
|
||||
| 状态 | 含义 | 进入 | 离开 |
|
||||
| Status | Meaning | Entering | Leaving |
|
||||
|---|---|---|---|
|
||||
| `Idle` | 空闲 | 任意完成态 | 收到消息 |
|
||||
| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 |
|
||||
| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 |
|
||||
| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 |
|
||||
| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 |
|
||||
| `Idle` | idle | any completion state | message received |
|
||||
| `AwaitClarify` | The goal/intent is unknown, waiting for the user to choose | The intent or email target is not unique | The user gives a clear choice |
|
||||
| `AwaitConfirm` | Wait for confirmation (AI analysis/attachment download) | User initiated analysis/download but not confirmed | Confirm/Cancel |
|
||||
| `Analyzing` | AI request in-flight | User confirmation analysis | Success/Failure |
|
||||
| `AwaitFallback` | AI failed, waiting for confirmation to fallback | 3 consecutive failed circuit breakers | User confirmation/cancel |
|
||||
|
||||
当时设定的不变量:`AwaitConfirm` / `Analyzing` / `AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**;会话状态为短期状态,统一走外部 Redis 短期 TTL(不使用 SQLite,丢失可接受)。渠道中立要求 FSM 状态与事件用领域类型,不依赖任何渠道 SDK。
|
||||
The invariants set at that time: `AwaitConfirm` / `Analyzing` / `AwaitFallback` involve AI or attachment downloading, ** LLM is not allowed to be called or attachments are not pulled until the confirmation state is reached **; the session state is short-term, and the external Redis short-term TTL is unified (SQLite is not used, and loss is acceptable). Channel neutrality requires that FSM status and events use domain types and do not rely on any channel SDK.
|
||||
|
||||
该不变量中「AI 分析必须先有用户显式授权」这一条**仍然有效**,已保留在 `docs/design.md` 的会话状态机一节;状态机本身未实现。
|
||||
The invariant "AI analysis must first be explicitly authorized by the user" is still valid and has been retained in the session state machine section of `docs/design.md`; the state machine itself is not implemented.
|
||||
|
||||
---
|
||||
|
||||
## 2. 虚构条目(文档曾写、代码从未有)
|
||||
## 2. Fictional entries (documentation has been written, code has never existed)
|
||||
|
||||
这些名字**从未在代码里存在**。它们有的出现在目标形态的目录树里,有的出现在未提交的草稿与讨论记录里,共同风险是让人误以为"这个功能已经实现",故在此登记防止再次出现。
|
||||
These names never exist in the code. Some of them appear in the directory tree of the target form, and some appear in unsubmitted drafts and discussion records. The common risk is that people mistakenly think that "this function has been implemented", so register them here to prevent their recurrence.
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
| Entry | Type | Reason | Alternative or Current Status |
|
||||
|---|---|---|---|
|
||||
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
|
||||
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
|
||||
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1130`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1328-1330`) |
|
||||
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
|
||||
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||
| Directory scheme that splits Telegram access into four files "module entry/command parsing/session state/reply rendering" | Fictitious directory scheme | Drawn in the target directory tree, never created | Command parsing is in `parse_intent` (`src/worker.rs`); the session/authorization state is Redis TTL (the AI authorization state section of `docs/reference.md`); the rendering function is in `src/channel.rs` |
|
||||
| `delivery:pending:{stream}` | Fictional key | Confused with Redis Streams' pending-entries list (PEL) - PEL is maintained internally by Redis and is not a writable key | Real key: `delivery:inflight:{stream}:{id}` (EX 60) and `delivery:committed:{stream}:{id}` (EX 604_800), see Delivery pipeline keys section of `docs/reference.md` |
|
||||
| `check_config_reload` | Fictional function | Uncommitted name in draft, never made it into code | Hot updated to `refresh_business_config` (`src/notify.rs:1130`) |
|
||||
| `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1328-1330`) |
|
||||
| `MESSAGWEAVE_DOMAIN` was once written as "Worker whitelist is invalid when production is not configured" | Fictional assertion (unsubmitted draft/appeared in discussion, not documented) | This environment variable has zero hits in all positions; Worker whitelist is **unconditional fail-closed**: unknown path 404, method does not match 405, backend is missing or parsing failure 503 (`cloudflare-worker/src/index.js:77-92`) | No need to configure switches, the whitelist will always take effect |
|
||||
| `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:390`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:491`) throws the Redis error as it is (`state.rs:531`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:499-507`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted |
|
||||
|
||||
---
|
||||
|
||||
| 把 LLM 能力建成领域子模块族(配置 / 回退 / 策略 / 审计分层) | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 |
|
||||
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::OutboundConfig`(经 `RuntimeConfigProvider` 下发),无 policy/audit 概念 | 无 |
|
||||
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
|
||||
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
|
||||
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
|
||||
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1315`)内联处理回调 URL 与验证码回写 | 无 |
|
||||
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
|
||||
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
|
||||
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
|
||||
| `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES` 环境变量 | 虚构环境变量 | `src/` 零命中(`LLM_MAX_RETRIES` 是 Redis 运行参数 `max_retries`,非环境变量);真实业务配置字段有 6 个:`LLM_ENABLED` / `LLM_ALLOW_NET` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_SUMMARY_TARGET_CHARS`(后者的值在构造器内硬编码,未进入 wire),另有 `llm_timeout_ms` / `max_retries` 两个运行时参数 | 见 `docs/reference.md` AI 授权态一节 |
|
||||
| `llm.call` tracing span | 虚构观测点 | `src/` 中除 `main.rs`(5 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 |
|
||||
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
|
||||
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
|
||||
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
|
||||
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:680`) | 无 |
|
||||
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
|
||||
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
|
||||
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
|
||||
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
|
||||
| Build LLM capabilities into domain sub-module families (configuration/fallback/policy/audit layering) | Fictitious module tree | There are only `jmap.rs` and `jmap/client.rs` under `domain/`; LLM only has one file `src/ai.rs` (`LlmClient`) | None |
|
||||
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | Fictional module | LLM is configured in `config.rs::LlmConfig`, runtime parameters are in `state.rs::OutboundConfig` (delivered by `RuntimeConfigProvider`), no policy/audit concept | None |
|
||||
| Build the channel layer into a directory module (including module entry file) | Fictitious directory | The channel layer is a file called `src/channel.rs` | None |
|
||||
| `notify::push_handler` / `notify::worker` / `notify::reconcile` as module paths | Fictitious module paths | These are free functions within `src/notify.rs` (`jmap_push` / `worker` / `reconcile`), not module paths | None |
|
||||
| `mod_dedup` / `mod_streams` / `mod_sincestate` | Fictional module name | `state.rs` / `notify.rs` are tile files, no submodules; deduplication and Streams logic exist as free functions | None |
|
||||
| `PushVerification` type | Fictitious type | Undefined; `register_push` (`notify.rs:1315`) handles callback URL and verification code writeback inline | None |
|
||||
| `CancellationToken` | fictitious type | unused; no graceful shutdown, no signal handling (`src/` zero hits `tokio::signal` / `ctrl_c`) | None |
|
||||
| `Preview` type / 4000 character long email protection / `[Continue to view original text]` button / `/llm-fallback` button | Fictional type and UI | All not implemented; after authorization, the full text will be handed over to LLM, and if it fails, the first 300 characters will be returned, without any truncation mark or button | None |
|
||||
| `LlmErr` (5 variants) | Fictional enum | Real is `AiError`, only 3 variants (`InvalidEndpoint` / `Request` / `Response`) | None |
|
||||
| `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES` environment variables | fictitious environment variables | `src/` zero hits (`LLM_MAX_RETRIES` is the Redis running parameter `max_retries`, non-environment variable); there are 6 real business configuration fields: `LLM_ENABLED` / `LLM_ALLOW_NET` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_SUMMARY_TARGET_CHARS` (the latter value is hard-coded in the constructor and not entered into the wire), and there are two runtime parameters `llm_timeout_ms` / `max_retries` | See `docs/reference.md` AI Authorization section |
|
||||
| `llm.call` tracing span | Fictitious observation point | **Zero tracing events, zero span** in `src/` except `main.rs` (5 events); no logs for LLM calls | None |
|
||||
| Circuit breaker / half-open state / 60s cooling after blowing / rule clearance (Redis share count) | No design implemented | Only timeout + retry; LLM failure will be silently downgraded to the first 300 words of fallback, no user-side prompts, no status records | None |
|
||||
| Scheduled summary/daily email summary push | Not implemented | Not implemented | None |
|
||||
| Attachment download (`send_document` / `Blob/get` / download button) | Not implemented | JMAP side read-only; email attachments only appear as `has_attachment: bool` | None |
|
||||
| `/flag` / `/unseen` / Send command | Not implemented | Currently 6 intents recognized (Help/Consent/Summary/Search/General Messages/Unrecognized); of which `/search` is implemented (`worker.rs:680`) | None |
|
||||
| `Identity` concept | Not implemented | Account identification only relies on `ACCOUNT_ID`, no identity layer abstraction | None |
|
||||
| Extract `RUN_MODE` into a separate configuration file | Fictitious splitting scheme | `RUN_MODE` was once read by `config.rs` and verified in `src/main.rs`. **This variable and the verification block have been deleted together** (see §4); there has never been such a function as `validate_env_or_exit` | None |
|
||||
| docker-compose `message-weave health --addr` example | fictitious command | Applies no CLI subcommand; health check endpoints are `GET /healthz` and `GET /ready` | See the Health-check table in `docs/deployment.md` |
|
||||
| Early design debate questions (message format/long message threshold/attachment policy/Identity/monitoring/LLM provider/circuit breaker, etc. 17 items) | Answered by code | The answer is given by the code that has been implemented and is no longer a pending item | See the "Early questions answered by code" section of `docs/design.md` |
|
||||
|
||||
## 3. 已删除的文档
|
||||
## 3. Deleted documents
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
| Entry | Type | Reason | Alternative or Current Status |
|
||||
|---|---|---|---|
|
||||
| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | 先由 `docs/roadmap.md` 取代,后者于 2026-09-29 更名并收窄为 `docs/opengaps.md`(只放仍未完成/未验证的缺口、阻塞与下一阶段目标) |
|
||||
| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/opengaps.md` |
|
||||
| `docs/todo.md` | Superseded (deleted) | Unclear structure, overlapping with design/deployment, and once carrying historical narratives such as "this round has been closed" | First replaced by `docs/roadmap.md`, which was renamed and narrowed to `docs/opengaps.md` on 2026-09-29 (only unfinished/unverified gaps, blockages and next-stage goals are included) |
|
||||
| Temporary handover files in the root directory (no such files are left) | Temporary handover files, **never entered the git history** | Handover content should be returned to the permanent document, no temporary files in the root directory are left | Contents are merged into `docs/design.md`, `docs/deployment.md`, `docs/reference.md`, `docs/opengaps.md` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已删除的兼容路径(代码曾存在、现已删除)
|
||||
## 4. Deleted compatibility path (code used to exist, now deleted)
|
||||
|
||||
与前两节不同:下列条目**曾经真实存在于代码**,既非虚构也非"未采用"。它们属于早期为兼容
|
||||
环境变量部署形态而保留的解析层,现已整体删除——项目早期无需任何兼容性承诺,删除比保留更便宜。
|
||||
删除后 `src/config.rs` 不含任何 `std::env` 读取:进程启动只读 2 个凭据加监听端口,其余业务字段
|
||||
全部走 Redis 业务配置(`/api/bootstrap` 或管理员 PUT → `validate_nonblank` fail-closed)。
|
||||
Unlike the previous two sections: the following items **actually existed in the code** and are neither fictional nor "unadopted". They are compatible with early
|
||||
The parsing layer retained by the environment variable deployment form has been deleted entirely - no compatibility commitment is required in the early stages of the project, and deletion is cheaper than retaining.
|
||||
After deletion, `src/config.rs` does not contain any `std::env`. Read: process startup only reads 2 credentials plus listening port, and other business fields
|
||||
All use Redis business configuration (`/api/bootstrap` or administrator PUT → `validate_nonblank` fail-closed).
|
||||
|
||||
| 条目 | 类型 | 删除原因 | 现状 |
|
||||
| Entry | Type | Reason for deletion | Current status |
|
||||
|---|---|---|---|
|
||||
| 原 `src/config.rs` 的 `Config::from_env()`(第 311 行) | 已删除函数 | 完整的 20 项环境变量解析器。凭据迁移到 Redis 业务配置后它成为唯一的兼容层,且已无任何调用方 | 启动期改由 `main.rs` 直读:`PORT`(:31)、`REDIS_URL`(:40)、`CONFIG_ENCRYPTION_KEY`(:49)、`DEBUG_TOKEN`(:83);业务字段由 `BusinessConfigWire` 承载 |
|
||||
| `required_secret` / `required_nonblank` / `env_bool` | 已删除助手函数 | 仅被上述解析器调用,随其一并删除 | 必填语义改由 `validate_nonblank` 在 Redis 业务配置路径承担(8 处调用) |
|
||||
| `RUN_MODE` 变量 + `Config.run_mode` 字段 + 启动校验块 | 已删除标识符 | 两种取值从未改变任何运行时行为:webhook 与 reconcile 共享同一套路由表,`POST /reconcile` 是独立端点。该变量只额外制造了一个必须被文档解释的分支 | 全仓零命中;`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` |
|
||||
| `jmap_password()` 安全访问器 | 已删除方法 | 为 `SecretString` 字段提供不泄密的读取入口,仅在 env 解析路径中有意义 | `jmap_password` 作为业务配置**字段名**保留(`BusinessConfigWire` / `BusinessConfig`),字段本身即 `SecretString` |
|
||||
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
|
||||
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
|
||||
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
|
||||
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification` 与 `pub mod jmap` 模块声明(`Notification` 在 worker.rs:330 在用);src/ 里只剩 `reload_async` 那 1 个 `expect(dead_code)`(测试用的重载 API) |
|
||||
| `Config::from_env()` (line 311) of old `src/config.rs` | Removed function | Full 20-item environment variable parser. After the credentials are migrated to the Redis business configuration, it becomes the only compatibility layer and there is no longer any caller | The startup period is changed to `main.rs` for direct reading: `PORT`(:31), `REDIS_URL`(:40), `CONFIG_ENCRYPTION_KEY`(:49), `DEBUG_TOKEN`(:83); business fields are carried by `BusinessConfigWire` |
|
||||
| `required_secret` / `required_nonblank` / `env_bool` | Deleted helper function | Only called by the above parser, deleted together with it | Required semantics changed to `validate_nonblank` in the Redis business configuration path (called at 8 places) |
|
||||
| `RUN_MODE` variable + `Config.run_mode` field + startup verification block | Deleted identifier | The two values never change any runtime behavior: webhook and reconcile share the same set of routing tables, `POST /reconcile` is an independent endpoint. This variable only creates an additional branch that must be interpreted by the documentation | Zero hit across the board; `NG-SERVER-MODE` remains in `docs/charter.md` as a design non-target |
|
||||
| `jmap_password()` secure accessor | Deleted method | Provides a non-disclosure read entry for the `SecretString` field, only meaningful in the env parsing path | `jmap_password` is reserved as a business configuration **field name** (`BusinessConfigWire` / `BusinessConfig`), the field itself is `SecretString` |
|
||||
| `Config.port` / `Config.redis_url` field | Deleted field | `port` is hard-coded `8080` in the two constructors, and the listening binding takes the value directly from the environment variable - the field value can be silently inconsistent with the real listening port, and the full position is zero-read; `redis_url` is also zero-read | `Config` is narrowed to 7 fields: `telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone` (the last item is mirrored from `BusinessConfig` for worker to render timestamp) |
|
||||
| `redis_url` parameters of two constructors | Deleted parameters | The only reader (original line 72 of main.rs) was removed in the previous round of transformation | `redis_only()` and `from_business(value)`; the wrappers `from_business_json` / `from_business_value` remove the first parameter synchronously |
|
||||
| 19 legacy environment variable names | Read removed | See above; these names no longer have any readers in the code | See `docs/reference.md` §5.3 for list; see `docs/design.md` §7.1 for semantics |
|
||||
| `Channel` / `Notifier` / `MessageAdapter` (`src/channel.rs`) + `UserCommand` (`src/domain.rs`) | Removed placeholder traits and types | The three **no implementation, no caller, no dyn binding**, the `#[expect(dead_code)]` attribute is the only source of reference; the actual Telegram outbound `channel::telegram::TelegramClient` is held directly by `MetadataWorker` and `notify.rs` of `worker.rs` and never passes through them. The only user of `UserCommand` is the deleted `Channel` | `channel.rs` leaves only `pub mod telegram` (`TelegramClient`, `reqwest` self-developed); `domain.rs` leaves only the domain `Notification` and `pub mod jmap` module declaration (`Notification` is used in worker.rs:330); only `reload_async` remains in src/ 1 `expect(dead_code)` (overloaded API for testing) |
|
||||
|
||||
> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量
|
||||
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量
|
||||
> (两个构造器内硬编码 300,未进入业务配置 wire)。
|
||||
> Note the difference between `LLM_MAX_RETRIES` and `LLM_SUMMARY_TARGET_CHARS`: the former is never an environment variable
|
||||
> (Redis running parameter `max_retries`, fallback to default, see `docs/reference.md` §6.1), the latter is a constant
|
||||
> (300 is hard-coded in the two constructors and has not entered the business configuration wire).
|
||||
|
||||
### 4.1 引导模式挂载的业务/管理路由
|
||||
### 4.1 Business/management routes mounted in boot mode
|
||||
|
||||
`router_configuration_setup` 原先直接委托全量工厂 `router_with_worker_state_runtime_bootstrap_config`,
|
||||
因此 12 条业务与管理路由在引导模式下全部被注册,却**一条也走不通**:引导模式的 `admin_token` 是空值,
|
||||
而 `constant_time_eq` 带 `!expected.is_empty()` 守卫,空的期望值会永久拒绝任何候选凭据;即便鉴权通过,
|
||||
`MemoryState` 也无法持久化一次 bootstrap 写入。
|
||||
`router_configuration_setup` originally directly entrusted the full factory `router_with_worker_state_runtime_bootstrap_config`,
|
||||
Therefore, all 12 business and management routes are registered in the boot mode, but none of them work: the `admin_token` in the boot mode is empty,
|
||||
And `constant_time_eq` is guarded with `!expected.is_empty()`. An empty expected value will permanently reject any candidate credentials; even if the authentication passes,
|
||||
`MemoryState` also cannot persist a bootstrap write.
|
||||
|
||||
| 条目 | 类型 | 删除原因 | 现状 |
|
||||
| Entry | Type | Reason for deletion | Current status |
|
||||
|---|---|---|---|
|
||||
| `/api/bootstrap`、`/api/admin/session`、`/api/admin/session/revoke`、`/api/config`、`/api/enabled`、`/api/business-config`、`/webhook/tg`、`/push/jmap`、`/api/push/register`、`/api/push/disable`、`/reconcile`、`/worker` | 已删除路由注册 | 永久 401 的路由读起来像"凭据写错了" | 引导模式自建 `AppState`,只挂 `/api/status`、`/ready`、`/healthz`;上述路径返回 `404` |
|
||||
| `/api/bootstrap`, `/api/admin/session`, `/api/admin/session/revoke`, `/api/config`, `/api/enabled`, `/api/bus iness-config`, `/webhook/tg`, `/push/jmap`, `/api/push/register`, `/api/push/disable`, `/reconcile`, `/worker` | Deleted route registration | Permanent 401 route reads like "wrong credentials" | Self-built `AppState` in boot mode, only `/api/status`, `/ready`, `/healthz`; the above path returns `404` |
|
||||
|
||||
`401` 与 `404` 的差别不是洁癖:`{"error":"unauthorized"}` 与"凭据填错"完全同形,操作员和探针会把
|
||||
"这些端点在此模式下根本不存在"误读成"再换个凭据试试"。现在两者可区分——`/api/status` 仍返回
|
||||
`{"missing":[...],"mode":"configuration-setup","ready":false}`(HTTP 200),业务与管理面直接 404。
|
||||
回归由 `configuration_setup_mounts_only_status_and_probes` 守护:逐一断言 12 条路径 404,并断言
|
||||
`/healthz` 200、`/api/status` 200、`/ready` 503。
|
||||
The difference between `401` and `404` is not mysophobia: `{"error":"unauthorized"}` is completely the same as "wrong credentials". Operators and probes will
|
||||
"These endpoints do not exist at all in this mode" was misread as "try again with different credentials". Now the two are distinguishable - `/api/status` still returns
|
||||
`{"missing":[...],"mode":"configuration-setup","ready":false}` (HTTP 200), business and management planes directly 404.
|
||||
Regression guarded by `configuration_setup_mounts_only_status_and_probes`: assert 12 paths 404 one by one, and assert
|
||||
`/healthz` 200, `/api/status` 200, `/ready` 503.
|
||||
|
||||
---
|
||||
|
||||
## 5. 如何重启其中一条
|
||||
## 5. How to restart one of them
|
||||
|
||||
1. 先在 `docs/opengaps.md` 登记为缺口并归属阶段。
|
||||
2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。
|
||||
3. 本文对应条目**保留**为决策依据——它记录的是当初为什么不这么做,不要删。
|
||||
1. First register as a gap in `docs/opengaps.md` and attribute it to the stage.
|
||||
2. If it involves Redis keys, TTL, HTTP routing or default values, update `docs/reference.md` in the same round.
|
||||
3. The corresponding entry in this article is **retained** as the basis for decision-making - it records why we did not do this in the first place and do not delete it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
# 废弃与未采用路线(Retired Routes)
|
||||
|
||||
> [English version / 英文版 → retired.md](retired.md)
|
||||
|
||||
> 本文只记录**三类**东西:①评估过但没采纳的路线;②真实存在过、后来被删掉的文档;③曾经写进文档、实际上从未存在或从未实现的名字。
|
||||
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
|
||||
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/opengaps.md`;当前设计见 `docs/design.md`。
|
||||
> 本文**没有行动项**。若某条变成要做的事,去 `docs/opengaps.md` 登记,本文条目保留为决策依据。
|
||||
> 本文**不引用任何不存在的路径**:凡路径,一律指向仓库中真实存在的文件;曾被写进文档但从未落地的路径只以文字描述其形态、不逐字复述,以免又被抄回正文或被人当成真实文件去查阅。
|
||||
> 验证基线:`b2dbe7c`。
|
||||
|
||||
---
|
||||
|
||||
## 1. Route not taken
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
|---|---|---|---|
|
||||
| `teloxide`(Telegram Bot 框架) | 未采用 | Telegram 只需少量 API 调用,不引入重框架(dptree / 会话中间件),减少依赖面与抽象层;`reqwest` 已按 `ARCH-DEPS-STAGE4` 引入 | `src/channel.rs` 用 `reqwest` 自研实现 |
|
||||
| teloxide 计划 feature 集(`macros` / `rustls` / `redis-session` / `throttle` / `webhooks-axum`) | 未实施 | 随 teloxide 未采用而失效 | — |
|
||||
| `teloxide-core` 降级方案 | 未采用 | 降级前提是「用 teloxide 太重」;既然整体不引入,降级前提不成立 | — |
|
||||
| `grammers` / 旧 `telegram-bot` crate | 未采用 | 直接封 Bot API + 自研即可满足需求 | `src/channel.rs` |
|
||||
| `BotError::Telegram(#[from] teloxide::errors::RequestError)` | 目标形态变体,未落地 | 依附 teloxide,随其废弃 | 无 |
|
||||
| `BotError::Jmap` / `Storage` / `RateLimited` / `Unauthorized` / `Llm` | 目标形态变体,未落地 | 当时为统一承接 JMAP/Redis/限流/授权/LLM 五类错误而设计;现已由各模块自行处理 | 当前 `BotError` 仅 `Config` / `Io` / `Json` / `State` 四变体(`src/error.rs` 全文) |
|
||||
| Telegram 服务端 30 msg/s 限流桶 | 目标设计,未实现 | 无真实 Bot 压测数据,不预设实现;服务端分桶由 TG API 自身承担,bot 侧只做退避 | 429 专用分支**已实现**(`src/channel.rs`:解析响应体 `parameters.retry_after` 并钳 ≤60s,优先用服务器指定值否则指数退避),因此本行不构成缺口;服务端限流桶的当前处置仍是 `max_retries`(默认 3,硬上限 5)通用重试 |
|
||||
| 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 |
|
||||
| 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 |
|
||||
| teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
|
||||
| docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | `src/main.rs` 无 CLI 子命令解析,该子命令不存在,照抄必失败(早期还叠加了镜像内无 `curl` 的问题,现已内置 `curl`) | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 |
|
||||
|
||||
| SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) |
|
||||
|
||||
### 1.1 Teloxide candidate comparison (evaluation record)
|
||||
|
||||
下表与下列理由为**当初的评估结论,保留它是决策依据,不是当前技术事实**——「活跃,最新 0.17,下载量大」等框架属性未经本轮复核,不代表这些 crate 的当前版本状态。
|
||||
|
||||
| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、webhooks+webhooks-axum、Redis 会话存储、throttle、macros、tracing、rustls | ★★★★★ | **首选** |
|
||||
| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 |
|
||||
| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 |
|
||||
|
||||
**当初倾向 teloxide 的 6 条理由**:
|
||||
1. 与 `jmap-client` 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
|
||||
2. 内建 Dispatcher + `UpdateKind` 枚举匹配命令,与命令路由天然契合。
|
||||
3. 支持 `webhooks-axum`(生产 Webhook 形态;不使用长轮询 `NG-LONG-POLLING`)与 Redis 会话存储(记住当前文件夹/分页游标,不用 SQLite)。
|
||||
4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。
|
||||
5. `tracing` feature 与本项目观测性统一。
|
||||
6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。
|
||||
|
||||
**当时的 feature 集计划**(阶段2 引入时):`macros`、`redis-session`、`throttle`、`tracing`,按需启用 `webhooks-axum`;TLS 侧 `rustls` 与 `rustls-native-roots` **按需二选一**(后者为前者补 OS 根证书,本机测试方便但生产多此一举)。
|
||||
|
||||
**替代/降级**:若 teloxide 升级或破坏性改动,可退到更薄的 `teloxide-core`(保留核心与 types,去掉 dispatcher 抽象);若需多账户高吞吐,用 `webhooks-axum` + 共享 `axum::Router`。以上两条均随 teloxide 未采用而不再成立。
|
||||
|
||||
### 1.2 Dialogue FSM state transition table (target design, not yet implemented)
|
||||
|
||||
当前没有 FSM:`src/worker.rs` 的 `parse_intent` 直接解析为 `Intent`,AI 授权是 Redis 里的一个布尔加过期时间。曾设计过下面 5 个状态:
|
||||
|
||||
| 状态 | 含义 | 进入 | 离开 |
|
||||
|---|---|---|---|
|
||||
| `Idle` | 空闲 | 任意完成态 | 收到消息 |
|
||||
| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 |
|
||||
| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 |
|
||||
| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 |
|
||||
| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 |
|
||||
|
||||
当时设定的不变量:`AwaitConfirm` / `Analyzing` / `AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**;会话状态为短期状态,统一走外部 Redis 短期 TTL(不使用 SQLite,丢失可接受)。渠道中立要求 FSM 状态与事件用领域类型,不依赖任何渠道 SDK。
|
||||
|
||||
该不变量中「AI 分析必须先有用户显式授权」这一条**仍然有效**,已保留在 `docs/design.md` 的会话状态机一节;状态机本身未实现。
|
||||
|
||||
---
|
||||
|
||||
## 2. Fictional entries (documentation has been written, code has never existed)
|
||||
|
||||
这些名字**从未在代码里存在**。它们有的出现在目标形态的目录树里,有的出现在未提交的草稿与讨论记录里,共同风险是让人误以为"这个功能已经实现",故在此登记防止再次出现。
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
|---|---|---|---|
|
||||
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
|
||||
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
|
||||
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1130`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1328-1330`) |
|
||||
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
|
||||
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||
|
||||
---
|
||||
|
||||
| 把 LLM 能力建成领域子模块族(配置 / 回退 / 策略 / 审计分层) | 虚构模块树 | `domain/` 下只有 `jmap.rs` 与 `jmap/client.rs`;LLM 只有 `src/ai.rs` 一个文件(`LlmClient`) | 无 |
|
||||
| `ai::config` / `ai::fallback` / `ai::policy` / `ai::audit` | 虚构模块 | LLM 配置在 `config.rs::LlmConfig`,运行时参数在 `state.rs::OutboundConfig`(经 `RuntimeConfigProvider` 下发),无 policy/audit 概念 | 无 |
|
||||
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
|
||||
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
|
||||
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
|
||||
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1315`)内联处理回调 URL 与验证码回写 | 无 |
|
||||
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
|
||||
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
|
||||
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
|
||||
| `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES` 环境变量 | 虚构环境变量 | `src/` 零命中(`LLM_MAX_RETRIES` 是 Redis 运行参数 `max_retries`,非环境变量);真实业务配置字段有 6 个:`LLM_ENABLED` / `LLM_ALLOW_NET` / `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_SUMMARY_TARGET_CHARS`(后者的值在构造器内硬编码,未进入 wire),另有 `llm_timeout_ms` / `max_retries` 两个运行时参数 | 见 `docs/reference.md` AI 授权态一节 |
|
||||
| `llm.call` tracing span | 虚构观测点 | `src/` 中除 `main.rs`(5 个事件)外**零 tracing 事件、零 span**;LLM 调用无任何日志 | 无 |
|
||||
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
|
||||
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
|
||||
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
|
||||
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:680`) | 无 |
|
||||
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
|
||||
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
|
||||
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
|
||||
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
|
||||
|
||||
## 3. Deleted documents
|
||||
|
||||
| 条目 | 类型 | 原因 | 替代或现状 |
|
||||
|---|---|---|---|
|
||||
| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | 先由 `docs/roadmap.md` 取代,后者于 2026-09-29 更名并收窄为 `docs/opengaps.md`(只放仍未完成/未验证的缺口、阻塞与下一阶段目标) |
|
||||
| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/opengaps.md` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Deleted compatibility path (code used to exist, now deleted)
|
||||
|
||||
与前两节不同:下列条目**曾经真实存在于代码**,既非虚构也非"未采用"。它们属于早期为兼容
|
||||
环境变量部署形态而保留的解析层,现已整体删除——项目早期无需任何兼容性承诺,删除比保留更便宜。
|
||||
删除后 `src/config.rs` 不含任何 `std::env` 读取:进程启动只读 2 个凭据加监听端口,其余业务字段
|
||||
全部走 Redis 业务配置(`/api/bootstrap` 或管理员 PUT → `validate_nonblank` fail-closed)。
|
||||
|
||||
| 条目 | 类型 | 删除原因 | 现状 |
|
||||
|---|---|---|---|
|
||||
| 原 `src/config.rs` 的 `Config::from_env()`(第 311 行) | 已删除函数 | 完整的 20 项环境变量解析器。凭据迁移到 Redis 业务配置后它成为唯一的兼容层,且已无任何调用方 | 启动期改由 `main.rs` 直读:`PORT`(:31)、`REDIS_URL`(:40)、`CONFIG_ENCRYPTION_KEY`(:49)、`DEBUG_TOKEN`(:83);业务字段由 `BusinessConfigWire` 承载 |
|
||||
| `required_secret` / `required_nonblank` / `env_bool` | 已删除助手函数 | 仅被上述解析器调用,随其一并删除 | 必填语义改由 `validate_nonblank` 在 Redis 业务配置路径承担(8 处调用) |
|
||||
| `RUN_MODE` 变量 + `Config.run_mode` 字段 + 启动校验块 | 已删除标识符 | 两种取值从未改变任何运行时行为:webhook 与 reconcile 共享同一套路由表,`POST /reconcile` 是独立端点。该变量只额外制造了一个必须被文档解释的分支 | 全仓零命中;`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` |
|
||||
| `jmap_password()` 安全访问器 | 已删除方法 | 为 `SecretString` 字段提供不泄密的读取入口,仅在 env 解析路径中有意义 | `jmap_password` 作为业务配置**字段名**保留(`BusinessConfigWire` / `BusinessConfig`),字段本身即 `SecretString` |
|
||||
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
|
||||
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
|
||||
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
|
||||
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification` 与 `pub mod jmap` 模块声明(`Notification` 在 worker.rs:330 在用);src/ 里只剩 `reload_async` 那 1 个 `expect(dead_code)`(测试用的重载 API) |
|
||||
|
||||
> 注意 `LLM_MAX_RETRIES` 与 `LLM_SUMMARY_TARGET_CHARS` 的区别:前者从来不是环境变量
|
||||
> (Redis 运行参数 `max_retries`,回落默认见 `docs/reference.md` §6.1),后者是常量
|
||||
> (两个构造器内硬编码 300,未进入业务配置 wire)。
|
||||
|
||||
### 4.1 Business/management routes mounted in boot mode
|
||||
|
||||
`router_configuration_setup` 原先直接委托全量工厂 `router_with_worker_state_runtime_bootstrap_config`,
|
||||
因此 12 条业务与管理路由在引导模式下全部被注册,却**一条也走不通**:引导模式的 `admin_token` 是空值,
|
||||
而 `constant_time_eq` 带 `!expected.is_empty()` 守卫,空的期望值会永久拒绝任何候选凭据;即便鉴权通过,
|
||||
`MemoryState` 也无法持久化一次 bootstrap 写入。
|
||||
|
||||
| 条目 | 类型 | 删除原因 | 现状 |
|
||||
|---|---|---|---|
|
||||
| `/api/bootstrap`、`/api/admin/session`、`/api/admin/session/revoke`、`/api/config`、`/api/enabled`、`/api/business-config`、`/webhook/tg`、`/push/jmap`、`/api/push/register`、`/api/push/disable`、`/reconcile`、`/worker` | 已删除路由注册 | 永久 401 的路由读起来像"凭据写错了" | 引导模式自建 `AppState`,只挂 `/api/status`、`/ready`、`/healthz`;上述路径返回 `404` |
|
||||
|
||||
`401` 与 `404` 的差别不是洁癖:`{"error":"unauthorized"}` 与"凭据填错"完全同形,操作员和探针会把
|
||||
"这些端点在此模式下根本不存在"误读成"再换个凭据试试"。现在两者可区分——`/api/status` 仍返回
|
||||
`{"missing":[...],"mode":"configuration-setup","ready":false}`(HTTP 200),业务与管理面直接 404。
|
||||
回归由 `configuration_setup_mounts_only_status_and_probes` 守护:逐一断言 12 条路径 404,并断言
|
||||
`/healthz` 200、`/api/status` 200、`/ready` 503。
|
||||
|
||||
---
|
||||
|
||||
## 5. How to restart one of them
|
||||
|
||||
1. 先在 `docs/opengaps.md` 登记为缺口并归属阶段。
|
||||
2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。
|
||||
3. 本文对应条目**保留**为决策依据——它记录的是当初为什么不这么做,不要删。
|
||||
|
|
@ -46,8 +46,10 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
|
||||
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
|
||||
'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md']
|
||||
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
|
||||
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
|
||||
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
|
||||
|
||||
FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)'
|
||||
EXT = r'\.(rs|js):(\d+)'
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
|
||||
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
|
||||
'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md']
|
||||
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
|
||||
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
|
||||
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
|
||||
|
||||
# `Ident` or `Module::Ident` inside backticks, optional () after.
|
||||
IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?'
|
||||
|
|
|
|||
|
|
@ -14,16 +14,18 @@ import sys
|
|||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md",
|
||||
"docs/deployment.md", "docs/reference.md", "docs/retired.md",
|
||||
"docs/opengaps.md", "docs/charter.md", "cloudflare-worker/README.md"]
|
||||
"docs/design.zh-CN.md", "docs/deployment.md", "docs/deployment.zh-CN.md",
|
||||
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md",
|
||||
"docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md",
|
||||
"docs/charter.md", "docs/charter.zh-CN.md", "cloudflare-worker/README.md"]
|
||||
|
||||
HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s")
|
||||
# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer
|
||||
SEC = re.compile(r"§(\d+(?:\.\d+)?)")
|
||||
# an explicit target file sitting immediately before the §ref
|
||||
TARGET_BEFORE = re.compile(
|
||||
r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design\.md|"
|
||||
r"deployment\.md|reference\.md|retired\.md|opengaps\.md|charter\.md)\s*[`\s]*"
|
||||
r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design(?:\.zh-CN)?\.md|"
|
||||
r"deployment(?:\.zh-CN)?\.md|reference(?:\.zh-CN)?\.md|retired(?:\.zh-CN)?\.md|opengaps(?:\.zh-CN)?\.md|charter(?:\.zh-CN)?\.md)\s*[`\s]*"
|
||||
r"§(\d+(?:\.\d+)?)")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ from pathlib import Path
|
|||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
|
||||
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
|
||||
'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md']
|
||||
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
|
||||
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
|
||||
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
|
||||
|
||||
CODE = re.compile(r'`[^`\n]*`')
|
||||
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import re, sys
|
|||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
|
||||
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
|
||||
'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md']
|
||||
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md',
|
||||
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
|
||||
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
|
||||
'docs/charter.md', 'docs/charter.zh-CN.md', 'cloudflare-worker/README.md']
|
||||
errors = []
|
||||
|
||||
def slug(t):
|
||||
|
|
|
|||
|
|
@ -1435,6 +1435,72 @@ async fn register_push(State(app): State<AppState>, headers: HeaderMap, body: By
|
|||
axum::Json(serde_json::json!({"push_subscription_id": subscription_id})).into_response()
|
||||
}
|
||||
|
||||
/// Registers Telegram's webhook without exposing the bot token to browser code.
|
||||
async fn register_telegram_webhook(
|
||||
State(app): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if !config_authorized(&app, &headers).await {
|
||||
return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false);
|
||||
}
|
||||
let Ok(request) = serde_json::from_slice::<PushRegistration>(&body) else {
|
||||
return error_response(StatusCode::BAD_REQUEST, "invalid_request", false);
|
||||
};
|
||||
let Ok(url) = url::Url::parse(&request.callback_url) else {
|
||||
return error_response(StatusCode::BAD_REQUEST, "invalid_request", false);
|
||||
};
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "invalid_request", false);
|
||||
}
|
||||
refresh_business_config(&app).await;
|
||||
let config = app
|
||||
.business_runtime
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|value| value.clone());
|
||||
let Some(config) = config else {
|
||||
return error_response(StatusCode::CONFLICT, "conflict", false);
|
||||
};
|
||||
let endpoint = format!(
|
||||
"https://api.telegram.org/bot{}/setWebhook",
|
||||
config.bot_token.expose_secret()
|
||||
);
|
||||
let result = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build();
|
||||
let Ok(client) = result else {
|
||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true);
|
||||
};
|
||||
let response = client
|
||||
.post(endpoint)
|
||||
.json(&serde_json::json!({
|
||||
"url": request.callback_url,
|
||||
"secret_token": config.telegram_webhook_secret.expose_secret(),
|
||||
"allowed_updates": ["message"]
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
let Ok(response) = response else {
|
||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true);
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return error_response(StatusCode::BAD_GATEWAY, "upstream_error", false);
|
||||
}
|
||||
let Ok(body) = response.json::<serde_json::Value>().await else {
|
||||
return error_response(StatusCode::BAD_GATEWAY, "upstream_error", true);
|
||||
};
|
||||
if !body
|
||||
.get("ok")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return error_response(StatusCode::BAD_GATEWAY, "upstream_error", false);
|
||||
}
|
||||
axum::Json(serde_json::json!({ "registered": true })).into_response()
|
||||
}
|
||||
|
||||
/// Disable and remove a persisted push registration. Destruction is attempted
|
||||
/// before deleting the callback mapping so a transient JMAP failure is
|
||||
/// retryable and cannot silently orphan a live subscription.
|
||||
|
|
@ -1762,6 +1828,10 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
|
|||
.route("/webhook/tg", post(telegram_webhook))
|
||||
.route("/push/jmap", post(jmap_push))
|
||||
.route("/api/push/register", post(register_push))
|
||||
.route(
|
||||
"/api/telegram/register-webhook",
|
||||
post(register_telegram_webhook),
|
||||
)
|
||||
.route("/api/push/disable", post(disable_push))
|
||||
.route("/reconcile", post(reconcile))
|
||||
.route("/worker", post(worker))
|
||||
|
|
@ -1980,6 +2050,7 @@ mod tests {
|
|||
("POST", "/webhook/tg"),
|
||||
("POST", "/push/jmap"),
|
||||
("POST", "/api/push/register"),
|
||||
("POST", "/api/telegram/register-webhook"),
|
||||
("POST", "/api/push/disable"),
|
||||
("POST", "/reconcile"),
|
||||
("POST", "/worker"),
|
||||
|
|
@ -2978,6 +3049,31 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_webhook_registration_requires_auth_and_https_url() {
|
||||
let (router, _state) = push_router(PushMockWorker {
|
||||
create_id: Some("sub-1"),
|
||||
destroy_ok: true,
|
||||
});
|
||||
let path = "/api/telegram/register-webhook";
|
||||
let body = r#"{"callback_url":"https://example.com/webhook/tg"}"#;
|
||||
let unauthorized = router
|
||||
.clone()
|
||||
.oneshot(push_req(path, body, None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
||||
let insecure = router
|
||||
.oneshot(push_req(
|
||||
path,
|
||||
r#"{"callback_url":"http://example.com/webhook/tg"}"#,
|
||||
Some("worker-secret"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(insecure.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_register_rejects_malformed_or_insecure_payload() {
|
||||
let (router, _state) = push_router(PushMockWorker {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,22 @@
|
|||
"login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.",
|
||||
"login.status": "Unable to create an admin session (HTTP {status}).",
|
||||
"login.fail": "Unable to connect to the admin service; check the network and retry.",
|
||||
"callback.eyebrow": "External callbacks",
|
||||
"callback.title": "Register Telegram and Stalwart callbacks",
|
||||
"callback.copy": "Save the business configuration first. Enter the public HTTPS origin that receives callbacks, then register both services from this page.",
|
||||
"callback.originLabel": "Public HTTPS origin",
|
||||
"callback.originPlaceholder": "https://mail.example.com",
|
||||
"callback.originHint": "Use the Worker URL when enabled; otherwise use the backend URL. Enter only the origin, without a path.",
|
||||
"callback.state": "Registration is an explicit action and can be repeated safely.",
|
||||
"callback.saveFirst": "Save the current business configuration before registering callbacks.",
|
||||
"callback.register": "Register callbacks",
|
||||
"callback.registerBusy": "Registering…",
|
||||
"callback.done": "Telegram and Stalwart callbacks registered.",
|
||||
"callback.partial": "Telegram registration: {telegram}. Stalwart registration: {stalwart}.",
|
||||
"callback.invalidOrigin": "Enter an HTTPS origin without a path, credentials, query, or fragment.",
|
||||
"callback.telegramOk": "registered",
|
||||
"callback.pushOk": "registered",
|
||||
"callback.failed": "failed",
|
||||
},
|
||||
zh: {
|
||||
"app.title": "业务配置 · MessageWeave",
|
||||
|
|
@ -411,6 +427,22 @@
|
|||
"login.fail": "无法连接管理服务,请检查网络后重试。",
|
||||
"timezone.title": "通知显示",
|
||||
"timezone.desc": "Telegram 通知中的收件时间按此时区渲染。",
|
||||
"callback.eyebrow": "外部回调",
|
||||
"callback.title": "注册 Telegram 和 Stalwart 回调",
|
||||
"callback.copy": "先保存业务配置,再输入接收回调的公开 HTTPS 地址。本页会向两个服务注册回调。",
|
||||
"callback.originLabel": "公开 HTTPS 地址",
|
||||
"callback.originPlaceholder": "https://mail.example.com",
|
||||
"callback.originHint": "启用 Worker 时填写 Worker 地址,否则填写后端地址。只填写站点 origin,不带路径。",
|
||||
"callback.state": "注册需要显式操作,可以安全地重复执行。",
|
||||
"callback.saveFirst": "请先保存当前业务配置,再注册回调。",
|
||||
"callback.register": "注册回调",
|
||||
"callback.registerBusy": "正在注册…",
|
||||
"callback.done": "Telegram 和 Stalwart 回调均已注册。",
|
||||
"callback.partial": "Telegram 注册:{telegram}。Stalwart 注册:{stalwart}。",
|
||||
"callback.invalidOrigin": "请输入不带路径、凭据、查询参数或片段的 HTTPS origin。",
|
||||
"callback.telegramOk": "已注册",
|
||||
"callback.pushOk": "已注册",
|
||||
"callback.failed": "失败",
|
||||
"timezone.hint": "仅支持固定偏移时区;不跟踪夏令时。",
|
||||
},
|
||||
});
|
||||
|
|
@ -576,6 +608,9 @@
|
|||
const logoutButton = document.querySelector("#logout-button");
|
||||
const defaultsButton = document.querySelector("#defaults-button");
|
||||
const businessSaveButton = document.querySelector("#business-save-button");
|
||||
const callbackOrigin = document.querySelector("#callback-origin");
|
||||
const callbackRegisterButton = document.querySelector("#callback-register-button");
|
||||
const callbackState = document.querySelector("#callback-state");
|
||||
const runtimeSaveButton = document.querySelector("#runtime-save-button");
|
||||
const businessSaveState = document.querySelector("#business-save-state");
|
||||
const runtimeSaveState = document.querySelector("#runtime-save-state");
|
||||
|
|
@ -629,6 +664,7 @@
|
|||
reloadButton.disabled = busy || !adminSession;
|
||||
logoutButton.disabled = busy || !adminSession;
|
||||
businessSaveButton.disabled = busy || !adminSession;
|
||||
callbackRegisterButton.disabled = busy || !adminSession || businessBaseline === null || businessSaveState.classList.contains("is-dirty");
|
||||
preflightButton.disabled = busy || !adminSession;
|
||||
runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession;
|
||||
defaultsButton.disabled = busy || !runtimeLoaded || !adminSession;
|
||||
|
|
@ -640,6 +676,7 @@
|
|||
businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? t("business.saveBusy") : t("business.save");
|
||||
runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? t("runtime.saveBusy") : t("runtime.save");
|
||||
preflightButton.textContent = busy && busyAction === "business-preflight" ? t("business.preflightBusy") : t("business.preflight");
|
||||
callbackRegisterButton.textContent = busy && busyAction === "callback-register" ? t("callback.registerBusy") : t("callback.register");
|
||||
}
|
||||
|
||||
function setBusy(action = "") {
|
||||
|
|
@ -1354,6 +1391,59 @@
|
|||
// can never disagree about whether the form is complete.
|
||||
preflightButton.addEventListener("click", runBusinessPreflight);
|
||||
|
||||
callbackRegisterButton.addEventListener("click", async () => {
|
||||
if (!adminSession || busyAction) return;
|
||||
if (businessBaseline === null || businessSaveState.classList.contains("is-dirty")) {
|
||||
callbackState.textContent = t("callback.saveFirst");
|
||||
return;
|
||||
}
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(callbackOrigin.value.trim());
|
||||
if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) throw new Error();
|
||||
} catch {
|
||||
callbackState.textContent = t("callback.invalidOrigin");
|
||||
callbackOrigin.focus();
|
||||
return;
|
||||
}
|
||||
const base = origin.origin;
|
||||
setBusy("callback-register");
|
||||
const results = { telegram: false, stalwart: false };
|
||||
try {
|
||||
try {
|
||||
await request("/api/telegram/register-webhook", "POST", { callback_url: `${base}/webhook/tg` });
|
||||
results.telegram = true;
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
expireSession(t("session.invalidBusiness"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await request("/api/push/register", "POST", { callback_url: `${base}/push/jmap` });
|
||||
results.stalwart = true;
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
expireSession(t("session.invalidBusiness"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (results.telegram && results.stalwart) {
|
||||
callbackState.textContent = t("callback.done");
|
||||
showNotice("success", t("callback.done"));
|
||||
} else {
|
||||
const text = t("callback.partial", {
|
||||
telegram: results.telegram ? t("callback.telegramOk") : t("callback.failed"),
|
||||
stalwart: results.stalwart ? t("callback.pushOk") : t("callback.failed"),
|
||||
});
|
||||
callbackState.textContent = text;
|
||||
showNotice("error", text);
|
||||
}
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
});
|
||||
|
||||
async function runBusinessPreflight() {
|
||||
if (!adminSession || busyAction) return;
|
||||
const config = readBusinessConfig();
|
||||
|
|
|
|||
|
|
@ -138,6 +138,15 @@
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel config-section" aria-labelledby="callback-title">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow" data-i18n="callback.eyebrow">External callbacks</p><h2 id="callback-title" data-i18n="callback.title">Register Telegram and Stalwart callbacks</h2></div>
|
||||
</div>
|
||||
<p class="section-copy" data-i18n="callback.copy">Save the business configuration first. Enter the public HTTPS origin that receives callbacks, then register both services from this page.</p>
|
||||
<div class="field field-wide"><label for="callback-origin" data-i18n="callback.originLabel">Public HTTPS origin</label><input id="callback-origin" type="url" inputmode="url" autocomplete="url" placeholder="https://mail.example.com" data-i18n-placeholder="callback.originPlaceholder"/><small data-i18n="callback.originHint">Use the Worker URL when enabled; otherwise use the backend URL. Enter only the origin, without a path.</small></div>
|
||||
<div class="form-footer"><p id="callback-state" class="save-state" aria-live="polite" data-i18n="callback.state">Registration is an explicit action and can be repeated safely.</p><div class="form-actions"><button id="callback-register-button" class="button button-primary" type="button" disabled data-i18n="callback.register">Register callbacks</button></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel config-section runtime-section" aria-labelledby="runtime-title">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow" data-i18n="runtime.eyebrow">Outbound requests</p><h2 id="runtime-title" data-i18n="runtime.title">Timeouts and retries</h2></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue