docs: default README to English, reconcile docs, drop HANDOFF

- README.md is now English; add README.zh-CN.md with cross-links; both versions
  cover the same 40 sections and the same key facts
- correct stale docs/design.md 10.5.1 and docs/todo.md to the current code
  (XAUTOCLAIM threshold, malformed-entry ACK, error envelope now done)
- replace the temporary HANDOFF.md with docs/todo.md (unfinished work + blockers)
- adjust docs/deployment.md and AGENTS.md accordingly
This commit is contained in:
anonsaber 2026-09-27 12:56:04 +08:00
parent 6868d47cd0
commit a8fad19daa
6 changed files with 992 additions and 146 deletions

239
AGENTS.md Normal file
View File

@ -0,0 +1,239 @@
# AGENTS.md — MessageWeave 开发守则
> 本文档是后续任何 AI coding agent 在本仓库工作的**操作守则与硬性约束**。
> 架构 / 模块接口 / 状态机 / 数据流 / 测试 / 实施阶段以 [docs/design.md](docs/design.md) 为准;
> 部署运维与发布(通用 HTTPS-only Docker / Secrets / Redis 状态层 / Webhook / Push / 外部 Cron 对账 / CI)以 [docs/deployment.md](docs/deployment.md) 为准。
>
> 三份文档职责分离,改动时请遵循 [§6 文档边界](#6-文档边界与引用关系),避免内容重复堆砌与跨文档冲突。
>
> **跨文档引用一律用稳定 ID**(`C-` / `NG-` / `MOD-` / `FLOW-` / `REQ-` 等),**不用章节号(§x.y)**;定义点位置与一句话说明见 [§7 跨文档引用索引](#7-跨文档引用索引表)。
---
## 1. 项目目标
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**(详见 [design.md §1](docs/design.md#1-任务与范围)):
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
- 利用 **JMAP Push HTTPS 回调**(唯一实时通道)+ 外部 Cron 对账(建议 5–10 分钟),新邮件到达时主动推送到 Telegram;**不使用** EventSource/SSE/长轮询(`NG-POLLING-SSE`/`NG-LONG-POLLING`,见 deployment.md `C-NO-LONG-CONN`)。
- **单账户**实现(`REQ-SINGLE-ACCOUNT`),多账户 = 多个 bot 实例;部署为**通用 HTTPS-only Docker 容器**(短请求模型,平台提供公网 HTTPS URL `C-HTTPS-URL`),详见 deployment.md。
- 可靠性目标:**通知可用性 ≥ 99.9%**,允许少量延迟(`NFR-NOTIFY-SLA`,策略见 deployment.md §6.4/§6.5)。
技术栈:`jmap-client`(rustls,**App Password + Basic** `C-AUTH-APP-BASIC`)+ Telegram Bot API(**版本不锁定**;渠道在 `src/channel.rs` 用 `reqwest` 自研实现,`teloxide` 未引入,见 `ARCH-DEPS-STAGE4`)+ tokio + **外部 Redis(用户托管 + AOF)**(`C-REDIS-MANAGED-AOF`;**服务端版本不锁定**,只要求能力集:Streams、XPING/PING、SET NX EX)。**未写代码前不得初始化 cargo 工程之外的内容**(仅文档阶段)。
---
## 2. 硬性安全边界(不可违反)
> 任何改动都不得破坏以下不变量。实现时必须有对应断言测试(见 §5 与 [design.md §9.3](docs/design.md#93-关键不变量测试))。
1. **查看原文 = JMAP 直取,绝不经 AI**:`/read` 与任何"看正文"路径调用 `JmapService::read_email`,LLM 不在查看路径上。
2. **AI 仅在用户明确要求并确认后接触正文**:用户明确发起"分析/总结/翻译"意图**并再次确认**(点 `[AI 总结]` / `/summarize`)后才把正文交给 LLM;未经确认,LLM 看不到正文。意图或目标不唯一 → 追问确认,禁止猜测执行。
3. **长邮件禁止发送完整原文**:正文 > 阈值(默认 4000 字符,见 [design.md §12.3](docs/design.md#123-正文获取策略与长邮件处理需求-1235))不发全文,只发预览 + `[AI 总结]`/`[继续查看原文(截断)]` 选项;"继续查看"也是截断版并标注"完整请电脑查看"。
4. **新邮件通知只含元数据**(`SAF-NOTIFY-META`):发件人 / 主题 / 时间(+附件数),绝不含正文;正文访问须用户显式读信/分析。
5. **附件默认不下载、不预载**:仅用户显式请求才触发 `Blob/get`;AI 永不接收附件内容。
6. **分析 / 摘要结果不持久化**:不写 Redis、不落盘、不缓存,即取即弃。
7. **AI 失败约 3 次 → 熔断 + 用户确认回退**:回退为规则模板 + 原文直取,标注"AI 不可用";恢复需用户选择。
8. **密钥不出仓库、不出日志**:`secrecy::SecretString` 包裹、`tracing` 屏蔽 `Authorization/password/token`;秘密一律运行期注入(`C-NO-SECRET-IN-IMAGE`,deployment.md)。变量命名以 `src/config.rs` 为准,模板见根目录 [`.env.example`](./.env.example)(**仅占位符,禁放真实值**)。
9. **聊天白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 必填;任何入站事件在做任何 JMAP/AI 调用或状态变更**之前**必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,否则直接拒绝终止。(阶段0 已完成解析骨架;强制拒绝随阶段2 渠道接入落地。)
10. **LLM 无工具权**:AI 输出仅为文本;删除/归档/下载/发送等动作只能由用户显式指令触发,绝不由 LLM 输出驱动(邮件正文视为不可信数据,正文内指令无效)。
11. **无长连接**:运行模式仅 `webhook`/`reconcile`/`health`,禁止 SSE/EventSource/WebSocket/Telegram 长轮询(`C-NO-LONG-CONN`/`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
12. **状态仅外部 Redis(用户托管 + AOF)**:会话/去重/Redis Streams/熔断计数/sinceState 一律走外部 Redis(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`),禁止 SQLite/本地卷作持久层(`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);Redis 丢失由 JMAP 对账 `FLOW-RECONCILE` 重建 sinceState,**事实源在 JMAP**。
13. **正文仅在用户明确允许后才外发 AI**(`REQ-AI-EXTERNAL-CONSENT`):默认不外发;发送前须用户显式同意(与第 2 条叠加)。
14. **单账户**(`REQ-SINGLE-ACCOUNT`):一个实例只接一个 Stalwart 账户;多账户 = 多个 bot 实例,不做多账户单实例。
15. **三入口鉴权 fail-closed(硬约束 `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` 校验请求体 `pushSubscriptionId` + `verificationCode`——按 `pushSubscriptionId` 查 Redis 短期验证状态(值存 `session_digest` 摘要,见 `src/notify.rs` `jmap_push`),状态缺失或摘要不符即拒绝;`/reconcile`、`/webhook/tg` 的凭证比较用**常数时间**(`subtle`),失败 `401` 且**在鉴权通过前不得产生任何副作用**;三密钥必填,缺失即启动失败,**禁止任何"未配置则放行"降级**。`/healthz`(`ARCH-HEALTHZ`)与 `/ready` 为**公开探针**(`SAF-PROBE-PUBLIC`:无鉴权、仅健康状态、不含敏感信息);**`/ready` 已做基础探测**(`ARCH-READY-BASELINE`):配置完整性 + Redis 可达性,不就绪返 `503`;**仍不做** JMAP session / TG getMe 等端到端探测(余下范围见 `GATE-READY-DEPS`)——**禁止**声称 `/ready` 已验证端到端依赖。
16. **多实例高可用(`ARCH-LB-WORKER`)**:允许同一镜像跨多个 serverless 平台实例化、前置免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`),共享同一 Redis(`C-REDIS-ONLY-STATE`)。**信任模型为透传(`SAF-LB-PASSTHRU`)——后端鉴权不可省**(小平台无防火墙/ACL,后端入口可能被公网直连);多实例**必须共享同一组 `SAF-AUTH-*` secret**(`C-LB-SHARED-SECRETS`)。`/reconcile` **禁止多实例并发**,用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`);Streams 用**同一消费组**分摊(`MOD-STREAMS-GROUP`);Worker 可提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。业务代码无需为 LB 改动。详见 deployment.md §10。
17. **生产红线(无数据库 / 无本地写入 / 标准输出日志,`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`)**:
- **不使用任何数据库**(`C-NO-DB`):无 SQLite / Postgres / MySQL / 嵌入式数据库;Redis 是唯一生产状态存储(`C-REDIS-ONLY-STATE`)。应用**不自建、不连接第二个数据库实例**。
- **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷(`NG-LOCAL-VOLUME`)。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用 `rolling-file` / `FileAppender` 等文件日志后端。
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏后的请求摘要;**禁止**写入密钥原文、JMAP 邮件正文、AI 请求/响应内容、附件内容。
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)必须由外部 Redis + JMAP 对账(`C-REDIS-ONLY-STATE` / `FLOW-RECONCILE`)实现;进程内缓存仅为性能优化,**丢失必须安全可重入**。
18. **无数据库/无本地写入/标准输出日志 = 发布门禁**:发布前必做 deployment.md §8.2 的 6 项红线自检(镜像无 DB 引擎、无本地可写挂载、日志仅 stdout/stderr、日志/Redis 无敏感数据、重启恢复不依赖进程内状态、Redis 由外部提供);任一失败禁止发布。
### 2.1 代码规模(软性指导,非硬限制)
- 单个 `.rs` 文件原则上不超过 **500 行**;这是**软性指导**而非硬性门禁。
- 复杂度、内聚性与可维护性优先:禁止为了凑 500 行制造奇怪的拆分(如过度抽象、跨文件跳转式"减肥")。
- 若要拆分,只按职责/内聚边界拆;超过 500 行本身不是错误,但**需要在 PR/提交说明中写明原因**(如"本文件保持命令表 + 全部子命令处理,超行因命令数量,拆开反而分散")。
### 2.2 代码注释与文档一致性(必须)
- 代码必须包含与设计文档匹配的必要注释;注释至少覆盖非显而易见的架构边界、状态流转、可靠性取舍、安全不变量和外部协议约束。
- `domain/`、`channel/`、`notify/`、Redis Streams、`sinceState`、AI 确认/回退、长邮件与附件处理等关键路径,必须能从代码注释追溯到 `design.md` 或稳定语义 ID(`C-*`、`REQ-*`、`FLOW-*`、`SAF-*`)。**注释中的稳定 ID 必须取自 §7 索引所用的前缀集**(`C-`/`NG-`/`MOD-`/`FLOW-`/`REQ-`/`SAF-`/`NFR-`/`GATE-`/`BOUND-`/`ARCH-`);**禁止自造未登记 ID**(如 `JMAP-n`、`DATA-n`、`JMAP-*`、`DATA-*`)——新增语义需先在 §7 登记再引用。
- 设计规则、安全边界或数据流变更时,必须同步更新受影响的代码注释和对应文档;禁止保留与实现不符的注释。
- 注释应解释“为什么”和约束,不要逐行复述代码;简单自解释代码不要求添加废话注释。
- Code review/测试应检查关键模块注释是否与文档一致;注释缺失或过时视为实现不完整,但不要求为每个函数添加固定格式的注释。
---
## 3. 实现顺序
按 [design.md §10](docs/design.md#10-分阶段实施计划) 推进(每阶段验收标准见对应小节):
```
阶段 0 脚手架 + HTTPS 入口骨架(已完成,待过 P0 门禁 GATE-P0)
—— 交付:cargo 工程 + 模块骨架(main/config/error/domain/channel/notify)
+ 单端口 axum 路由占位(/webhook/tg /push/jmap /reconcile /healthz /ready)
+ /healthz=liveness;/ready 阶段0 交付为占位 200,现已演进为基础探测(ARCH-READY-BASELINE:配置完整性 + Redis 可达性,不就绪→503;仍不做端到端探测,余下见 GATE-READY-DEPS)
+ env 配置骨架 + 最小测试;实际依赖:axum 0.8 / serde / serde_json / thiserror / secrecy / subtle / url / tokio / tracing;jmap-client =0.4.2(default-features=false, features=["async","rustls"])
+ 入口鉴权已落地:/reconcile(Bearer) /webhook/tg(secret 头) /push/jmap(verificationCode),fail-closed SAF-AUTH-*
阶段 1 JMAP 只读(`jmap-client` 0.4.2 **已引入**,`ARCH-DEPS-STAGE1`;R1 入口鉴权已在阶段0 落地,不重复实现)
+ `JMAP_SESSION_URL` 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`):接受服务基地址或完整 /.well-known/jmap,交给 jmap-client 前归一化为 origin/base;仅 HTTPS、禁内嵌凭据
+ `read_email` 多 part 原文拼接(`REQ-JMAP-RAW-MULTIPART`)
阶段 2 渠道适配骨架(Channel/Notifier/MessageAdapter + 首个渠道 Telegram;CHAT_ALLOWLIST 强制 + throttle;/start /folders /list /read)
阶段 3 发送 + 状态(send_email draft+submission;/send FSM;/flag)
阶段 3.5 LLM 门面 + 回退(见 design.md §12.9)
阶段 4 实时推送(Push 回调 + Redis Streams worker + 外部 Cron 对账 + sinceState→Redis)
阶段 5 搜索 + 搜索片段 + 打磨
```
> **阶段门禁**:`GATE-P0`(阶段0 P0 门禁,8 项)与 `BOUND-STAGE1`(阶段1 推进边界)定义见 design.md §10.0。**未过 `GATE-P0` 不得进入阶段1**;阶段0 业务体为无副作用占位,**不应暴露公网**。**注意**:三条写路径的**入口鉴权 `R1` 已在阶段0 落地**(`SAF-AUTH-*`,fail-closed),阶段1 不再重复实现,也**禁止放宽/绕过**。
> **依赖引入节奏**:`jmap-client` **已引入**(`=0.4.2`,实际 `default-features = false, features = ["async","rustls"]`,`ARCH-DEPS-STAGE1`:版本与 features **以 `Cargo.toml` 为准**;⚠️ 其默认 features `["async","websockets","aws_lc_rs"]` **含 WebSocket 栈**,故须关闭默认 features 且不选 `websockets`,以遵守 `C-NO-LONG-CONN`);`redis`(`0.27`) / `reqwest`(`0.13`) **已加入 Cargo.toml**(阶段3.5/4 已落地,版本**以 `Cargo.toml` 为准**,`ARCH-DEPS-STAGE4`);`teloxide` **未引入**——Telegram 渠道在 `src/channel.rs` 用 reqwest 自研实现,不依赖 teloxide 类型(`src/domain.rs` 注释禁止 teloxide 类型跨越领域边界);不得在文档/注释中声称已使用未引入的依赖。配置为**环境变量手工解析**(`ARCH-CONFIG-ENV`,无 figment/TOML)。`ACCOUNT_ID` **可选**:留空 → 取 JMAP session 的**默认/主账户**;显式值经校验后使用(`REQ-SINGLE-ACCOUNT`)。
> 部署/镜像相关工作(Dockerfile、compose、健康检查、CI 发布)不是各功能阶段的目标,集中在 deployment.md;功能阶段验收通过 `cargo test`,不需要先做镜像。
> **JMAP session URL 语义**(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`):`JMAP_SESSION_URL` 可填**服务基地址**(`https://host[:port]`)或**完整** `…/.well-known/jmap`;代码在 `Client::connect` 前归一化为 **origin/base**(jmap-client 自行追加 `/.well-known/jmap`),**不得出现重复路径**(`…/.well-known/jmap/.well-known/jmap`)。仅 **HTTPS**;**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`);拒绝危险 query。
---
## 4. 禁止事项
- 禁止把 AI 输出当作可执行操作(LLM 无工具权)。
- 禁止未确认就把正文 / 附件内容送入 LLM;禁止把分析结果持久化(Redis/磁盘/缓存)。
- 禁止在生产路径绕过 chat 白名单、关闭 TLS 校验、关闭熔断/回退。
- 禁止把 secrets(token / 密码 / LLM API key)写入代码、配置、日志或镜像。
- 禁止把部署/平台细节塞回 `design.md`(见 §6);反之亦然。
- 禁止引入 Alpine/musl 基础镜像或非 Docker 部署形态(deployment.md 硬约束 C-DOCKER/C-DEBIAN-SLIM)。
- 禁止引入 SSE/EventSource/WebSocket/长轮询等长连接运行模式(`C-NO-LONG-CONN`/`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
- 禁止 SQLite/本地卷作持久层;状态与游标仅外部 Redis(`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`/`C-REDIS-ONLY-STATE`),Redis 丢失走 JMAP 对账 `FLOW-RECONCILE` 恢复。
- 禁止无授权改动 `docs/design.md`、`docs/deployment.md`、`AGENTS.md` 之外的内容;安全边界变更须同步三份文档。
- 禁止在领域层(`jmap` / `llm` / 意图状态机 / `notify::core`)引入 Telegram/teloxide 类型;`teloxide` 仅允许出现在 `channel/telegram/`。
- 禁止提前实现钉钉/飞书 adapter(仅保留抽象与扩展位,不写具体渠道代码、不建渠道专属配置项)。
- 禁止在文档/注释中声称已使用**尚未加入 `Cargo.toml`** 的依赖(`teloxide`/`figment` 等);`jmap-client`/`redis`/`reqwest` 已引入(版本以 `Cargo.toml` 为准,`ARCH-DEPS-STAGE4`),其中 jmap-client **禁止启用其 WebSocket feature**(`C-NO-LONG-CONN`);配置恒为环境变量手工解析(`ARCH-CONFIG-ENV`),禁止引入 figment/TOML 配置文件。
- 禁止在未通过 `GATE-P0` 时进入阶段1;禁止把阶段0 容器暴露公网(业务体仍为占位,`BOUND-STAGE1`)。入口鉴权(`SAF-AUTH-*`,R1)**已在阶段0 落地**,禁止移除、放宽或绕过(fail-closed)。
- 禁止在**透传 LB 模型**(`SAF-LB-PASSTHRU`)下削弱/关闭任何后端的 `SAF-AUTH-*` 校验(小平台无防火墙,后端可能被公网直连);禁止让多实例使用**不同**的 `SAF-AUTH-*` secret(`C-LB-SHARED-SECRETS`);禁止让 `/reconcile` 多实例并发(必须 Redis 锁,`SAF-RECONCILE-LOCK`)。
- 禁止引入**任何数据库**(`C-NO-DB`):不引入 SQLite/Postgres/MySQL/嵌入式数据库;不连接 Redis 之外的第二个状态存储。
- 禁止**本地文件/目录写入**(`C-NO-LOCAL-WRITE`):不写日志文件、不写数据文件、不写临时缓存、不挂载本地卷。
- 禁止将日志写入非 stdout/stderr 的目的地(`C-LOG-STDOUT-ONLY`):不用 `rolling-file`/`FileAppender`/自定义文件 sink;日志只能输出到容器标准输出/错误,由平台采集。
- 禁止把**密钥、邮件正文、AI 请求/响应内容、附件内容**写入日志或 Redis(`SAF-LOG-PURITY`):日志/Redis 仅允许结构化事件、计数、时间戳、脱敏摘要。
- 禁止依赖**进程内状态**做生产恢复(`C-NO-STATEFUL-RECOVERY`):任何重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)必须能从 Redis + JMAP 对账重建;进程内缓存仅为性能优化,丢失必须安全可重入。
---
## 5. 测试验收(至少满足)
- `cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test` 全绿;**并在 Debian `rust:1-slim-bookworm` 容器内执行**(`GATE-P0`/`C-DEBIAN-SLIM`;阶段0 P0 门禁要求)。
- 占位符使用**局部** `#[expect(dead_code, reason="稳定ID+阶段0占位")]`;**禁止 crate 级 `#[allow]`**;不得为过 clippy 删除安全访问器(如 `JMAP_PASSWORD` getter)或让 `Debug` 泄密。
- 不变量测试([design.md §9.3](docs/design.md#93-关键不变量测试)):
- **VIEW / 查看原文**:mock AI 端点零请求(断言 LLM client 未被调用)。
- **新邮件通知**:消息内无正文内容(断言泄漏)。
- **长邮件**:正文 > 4000 字符不发送全文,仅预览 + 选项。
- **AI 前置确认**:未确认前 LLM 零调用;确认后才发起请求。
- **结果不落盘**:分析处理后无新增磁盘/Redis 写入路径。
- **AI 3 次失败**:触发熔断 → 弹确认 → 回退带"AI 不可用"徽标;`LLM_ENABLED=false` 全走回退。
- 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`/`/webhook/tg`/`/push/jmap` 在缺失或错误凭证下返回 `401` 且**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200`;`/ready` 已做基础探测(`ARCH-READY-BASELINE`:配置完整性 + Redis 可达性,不就绪返 `503`)——测试**可以**断言 `/ready` 在基础依赖不就绪时返回 `503`,但**不得**断言其已执行 JMAP session / TG getMe 等端到端探测(余下范围 `GATE-READY-DEPS`);两者响应体均不含敏感信息。
- **JMAP 只读 adapter(`MOD-JMAP-CLIENT`/`GATE-G1-JMAP-READONLY`)**:mock 测试覆盖 session/account 选择(`ACCOUNT_ID` 空→主账户、显式值校验)、**URL 归一化**(`JMAP_SESSION_URL` 基地址与完整 `…/.well-known/jmap` 两种输入结果一致、无重复路径、`http://` 拒绝、内嵌凭据拒绝,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、`list_folders`/`list_emails`(`limit` 边界)/`read_email`(多 part 拼接与"无可用部分"明确错误,`REQ-JMAP-RAW-MULTIPART`)、`received_at` 解析;真机测试用 `#[ignore]` 标记、经环境变量驱动(运行:`cargo test -- --ignored jmap::`;仅编译:`cargo test --no-run`),**缺环境时清晰跳过且不泄密**,CI 默认不跑真机用例。**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行 `--ignored` 前不得声称"真机通过"。**
- 领域/渠道解耦测试:`jmap`/`llm`/意图状态机/`notify::core` 不 import teloxide 类型(编译期/脚本检查);`MockChannel` 可驱动全部领域与通知流程(不依赖 Telegram)。
- 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。
- 代码注释一致性:抽查关键领域、渠道、通知、Redis/AI 模块,确认注释引用的稳定语义 ID、状态流转和安全边界与 `design.md`/`deployment.md` 一致。
- 涉及部署的改动需过 deployment.md 的 CI/验收清单(`C-DOCKER` 镜像扫描、非 root、health 端点等)。
- 涉及部署/日志/状态的改动需过 deployment.md §8.2 红线自检 6 项(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY` / 外部 Redis);任一失败禁止发布。
---
## 6. 文档边界与引用关系
| 文件 | 内容 | 何时更新 |
|---|---|---|
| `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、产品/架构类待确认问题 | 行为/接口变更时 |
| `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、2 项待确认(`Q-DEP-A` 监控日志落盘 / `Q-DEP-B` 日志采集) | 部署/发布变更时 |
| `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 |
| `README.md` / `README.zh-CN.md` | 面向最终用户的项目简介、安全模型、配置/环境变量、首次联调流程、已知限制(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 |
| `docs/todo.md` | 未完成项与阻塞原因(替代已删除的根目录 `HANDOFF.md`) | 待办项增减时 |
- 改动产品规则必须同步三份文档的相关表述,保持一致、不重复堆砌。
- `design.md` 不写部署细节,`deployment.md` 不写产品行为/接口;交叉处用**稳定 ID**(§7 索引)互相引用,不用章节号。
---
## 7. 跨文档引用索引表
> 约定:跨文档引用一律用下表的**稳定 ID**(不用 `§x.y` 章节号)。ID 语义稳定、全仓唯一、可 grep;内容搬家时**只更新本表的"文件/锚点"列**,所有引用本身零改动。
> 新增约束/组件时,先在本表登记一行,再在定义点写 `**[ID]**` 标签。
| 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 / 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` 常驻,非目标 | 非目标 |
| `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 / 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 / AGENTS.md §2 | 仅用户明确允许后才向外部 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-RECONCILE-IDEMPOTENCY` | src/state.rs `ReconcileState` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |
| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 |
| `SAF-NOTIFY-META` | AGENTS.md §2 | 新邮件通知只含元数据,正文不入通知 | 安全 |
| `SAF-CHAT-ALLOWLIST` | AGENTS.md §2 / design.md §7.3 | CHAT_ALLOWLIST 硬约束,处理前先拒绝非白名单 | 安全 |
| `SAF-AUTH-RECONCILE` | AGENTS.md §2 / design.md §7.3 | `/reconcile` 需 `Authorization: Bearer RECONCILE_TOKEN`,fail-closed | 安全 |
| `SAF-AUTH-TG-WEBHOOK` | AGENTS.md §2 / design.md §7.3 | `/webhook/tg` 需头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET` | 安全 |
| `SAF-AUTH-JMAP-PUSH` | AGENTS.md §2 / design.md §7.3 | `/push/jmap` 按 `pushSubscriptionId` 查 Redis 短期验证状态(存 `session_digest` 摘要),缺失或摘要不符拒绝;状态缺失回落 worker 重新验证(fail-closed) | 安全 |
| `SAF-ADMIN-SESSION` | src/config.rs `admin_session_digest` / §7 | 返回用于 Redis admin-session 记录的 SHA-256 摘要;bearer token 本体绝不写入 Redis | 安全 |
| `SAF-NO-SECRET-ECHO` | src/config.rs `SecretString` 字段与公共配置 API | 密钥字段不实现 `Debug` 且永不进入公共配置 API 响应;Redis 线格式类型私有,不得序列化进 HTTP 响应 | 安全 |
| `SAF-PROBE-PUBLIC` | AGENTS.md §2 / 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` | `/ready` 基础探测:配置完整性 + Redis 可达性,不就绪返 `503`;**不**含 JMAP session / TG getMe 端到端探测(阶段0 交付时曾为占位 200,现已演进) | 架构 |
| `GATE-READY-DEPS` | docs/design.md §10.0 | 余下门禁:`/ready` 补 JMAP session / TG getMe 等**端到端**依赖探测(阶段3.5+ 的剩余部分,含其测试与部署接线) | 流程 |
| `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 / AGENTS.md §3 | 阶段0 实际依赖集(axum/serde/secrecy/subtle/tokio/tracing…) | 架构 |
| `ARCH-DEPS-STAGE1` | docs/design.md §10.0-1 / AGENTS.md §3 | 阶段1 依赖现况:`jmap-client 0.4.2` 已引入,版本/features 以 Cargo.toml 为准,禁用 WebSocket feature | 架构 |
| `ARCH-DEPS-STAGE4` | docs/design.md §10.0-1 / AGENTS.md §3 | 阶段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 / AGENTS.md §3 | `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),待真机验证** | 需求 |
| `SAF-JMAP-URL` | docs/design.md §7.1 / AGENTS.md §3 | 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 / AGENTS.md §5 | 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 / AGENTS.md §2 | 信任模型=透传: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 聚合健康视图,报告各后端存活供外部监控 | 组件 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |
| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 |
| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 |
| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 |

339
README.md
View File

@ -1,114 +1,321 @@
# MessageWeave
一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。
中文文档:[README.zh-CN.md](README.zh-CN.md) · Architecture & design: [docs/design.md](docs/design.md) · Deployment & operations: [docs/deployment.md](docs/deployment.md)
> ⚠️ **当前仍属早期阶段,还不能真正使用。**
> 目前只有底层功能骨架;**真实邮箱的联调还没验证**。仓库已提供 Debian Dockerfile,但仍不建议生产部署。
A personal email assistant that connects your **Stalwart mailbox** to **Telegram**: you get notified in Telegram when a new email arrives, and you can read or summarise emails on demand.
> ⚠️ **This is an early-stage project — please complete a small-scale integration test first.**
> Real mailboxes and Telegram still need to be verified by the operator. JMAP Push subscription registration and verification write-back are implemented, but the callback URL is never guessed automatically: an administrator must call the registration API explicitly.
---
## 这个项目是什么
## What this project is
- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。
- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。
- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。
- You run your own [Stalwart](https://stalwart.dev) mail server.
- This tool connects your mailbox to a Telegram bot: when a new email arrives, it pushes a notification (sender / subject / time only — **no message body**).
- In Telegram you can ask to view the raw email, or to have it summarised or translated by an AI.
### 隐私原则
- **查看原文不会经过任何 AI**,直接读邮箱。
- **只有在你明确要求并确认后**,才把正文发给 AI 分析。
- 分析结果不会被保存。
- AI 可以完全不配置、完全关闭。
### Privacy model
- **Viewing the raw email never touches any AI.** It is read straight from your mailbox.
- **The body is sent to an AI only when you explicitly ask for it and confirm.**
- Analysis results are never stored.
- The AI is optional and can be left unconfigured or fully disabled.
---
## 部署前,你需要准备这几样东西
## What you need before deploying
1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。
2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。
3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。
4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。
5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。
1. **A Stalwart mailbox account**, with an **App Password** generated for it — not your account's master password.
2. **A Telegram bot** (create one via @BotFather), and its **Bot Token**.
3. **A Redis database** that you operate (AOF persistence recommended). All of the bot's state lives there. Use `rediss://` for TLS Redis and `redis://` for plain connections; if your password (Upstash etc.) contains `@`, `:`, `/` or `#`, URL-encode it first.
4. **A public HTTPS address.** The platform points this address at the bot container (HTTPS is provided by the platform).
5. **An external cron / scheduler** that calls `/reconcile` every 5–10 minutes so the bot periodically reconciles against your mailbox. The bot does not run any timers of its own.
### 需要登记的地址
- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg`
- 告诉 Stalwart:把 Push 回调指向 `你的公网地址/push/jmap`
### Addresses that must be registered
> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面三个 URL 指向 **Worker 的稳定域名**(`https://<your-worker>.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。
- Tell Telegram to send its webhook to `your-public-address/webhook/tg`.
> If you use the optional "multiple instances + Cloudflare Worker load balancer" setup (see `docs/deployment.md` §10), point the Telegram webhook and the reconcile URL at the **Worker's stable domain** (`https://<your-worker>.workers.dev/webhook/tg` etc.) instead of the per-instance platform addresses.
---
## 在 Docker 管理器中创建容器
## Creating the container in your Docker manager
创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据:
Create a new container, fill in the usual image / port / memory fields, and inject only the Redis credentials:
| 环境变量 | 必须? | 填什么 |
| Environment variable | Required? | Value |
|---|---|---|
| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` |
| `REDIS_URL` | ✔ | Redis connection string (including password), e.g. `redis://:password@redis.example.com:6379/0` |
| `CONFIG_ENCRYPTION_KEY` | ✔ | A 32-byte hex key; generate with `openssl rand -hex 32` |
首次启动后,通过受保护的 HTTPS `/api/bootstrap` 提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。
After the first start, submit the Telegram / JMAP / LLM and business-auth configuration through the protected SPA page. Configuration secrets are encrypted into Redis and are never returned by the API; after a successful admin `PUT` the service validates the new configuration and hot-rebuilds the clients atomically, so subsequent requests use it immediately, and on failure the previous instances are kept.
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
AI summarisation is off by default. A Telegram user must first send one of the recognised consent phrases, then `/summary <email_id>`: `/ai on` or `/ai yes` (1 hour); any message containing `临时` or `一次`, e.g. `临时一次` (one-off, 1 hour); `今天` (24 hours); `7天` (7 days); or `直到我撤销` / `长期` (until revoked, capped at 365 days). These phrases are matched literally, so type them in Chinese exactly as shown — English aliases are not recognised. To revoke: `/ai off` (or `撤销授权`). Authorisation never auto-renews once it expires. Without a valid authorisation only metadata is returned, and if the LLM is unset or fails the bot falls back to a locally truncated summary.
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
> If `REDIS_URL` or `CONFIG_ENCRYPTION_KEY` is missing, the service enters *configuration-setup* mode: the SPA, `/api/status` and the probes still work. The SPA hides the admin-session section and shows only which variables are missing, together with the note that keys are never stored or echoed. Once the configuration is restored, the authorisation UI appears only after `/api/status` reports `ready=true`.
### HostStack 原生部署
### HostStack native deployment
HostStack 使用根目录 `hoststack.yaml` 构建并运行 Rust 服务:执行
`cargo build --release --locked`,运行 `./target/release/message-weave`,并以 `/healthz`
进行 30 秒间隔、5 秒超时的健康检查。请在 HostStack Secret 中注入 `REDIS_URL` 与
`CONFIG_ENCRYPTION_KEY`;密钥不写入 YAML、镜像或仓库文件。
HostStack builds and runs the Rust service using the repository-root `hoststack.yaml`: it runs `cargo build --release --locked`, executes `./target/release/message-weave`, and uses `/healthz` as a health check every 30 seconds with a 5-second timeout. Provide `REDIS_URL` and `CONFIG_ENCRYPTION_KEY` as HostStack Secrets; the keys are never written into the YAML, the image, or any repository file.
管理会话内的“启用业务处理”开关持久化在 Redis `config:enabled`;缺失或读取失败均按关闭处理。
关闭时 Webhook、Push、Reconcile 和 Worker 返回 HTTP 503,不确认或丢弃上游事件;SPA、状态和管理配置 API 仍可用。
The "enable business processing" switch in the admin session is persisted in Redis at `config:enabled`; a missing value or a read failure is treated as disabled. While disabled, the webhook, reconcile and worker routes return HTTP 503 and neither ack nor drop upstream events; the SPA, status and configuration APIs stay available.
---
## 启动后怎么看它是否正常
## How to check that it is healthy after startup
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
- 目前 `/ready` 只是占位,**还不能用它判断邮箱/Redis 是否正常**(这个检查以后才会补上)。
- Open **`your-public-address/healthz`** in a browser or with `curl`: a normal response means the bot process is alive.
- `/ready` returns a JSON readiness report and checks configuration completeness and Redis reachability: 200 means the basic dependencies are ready, 503 means it should not receive traffic. It never calls JMAP or Telegram and never triggers a business side effect.
- For monitoring, Uptime Kuma is recommended: an HTTP liveness check on `/healthz` and an HTTP readiness check on `/ready` (expect 200, 30-second interval, 5-second timeout). The project ships no Prometheus exporter.
- `/reconcile` performs authentication, takes a Redis single-flight lock, and runs a bounded JMAP `Email/changes` reconciliation. Events are written to Redis Streams idempotently, and the Redis `sinceState` cursor is only advanced after all of them are queued; a temporary dependency failure returns `503` so cron can retry. Push events still go through the authenticated, de-duplicated enqueue path, so this must not be described as complete real-time synchronisation.
- The cold-start baseline also has page, message and time budgets. When a budget is exhausted the checkpoint is saved and the next `/reconcile` continues from it, so a large mailbox is never re-scanned from the beginning.
## 配置管理
## Configuration management
打开服务根地址 **`你的公网地址/`**,输入 `REDIS_URL` 中 Redis ACL 用户的密码,创建有效期 15 分钟的管理会话。会话与表单中的密钥只保存在当前页面内存;刷新或关闭页面后需要重新授权。页面不使用浏览器存储或 Cookie 保存会话、密钥和配置。
Open the root address **`your-public-address/`** and enter the password of the Redis ACL user from `REDIS_URL` to create an admin session valid for 15 minutes. The session and the keys typed into the form live only in the current page's memory; after a refresh or closing the page you must authorise again. The page never stores sessions, keys or configuration in browser storage or cookies.
页面分开管理两类配置:运行参数(JMAP、Telegram、LLM 请求超时与最大重试次数)可通过 API 读取和保存;业务配置(JMAP、Telegram、LLM、chat allowlist 与各鉴权 token)由 `PUT /api/business-config` 完整替换。为避免密钥回显,服务端没有业务配置读取接口,因此每次完整替换都必须重新输入必填项。保存收到 HTTP 204 后页面会提示新配置已热加载,并清空密钥输入框。
The page keeps two kinds of configuration separate: runtime parameters (JMAP, Telegram, LLM timeouts and retry limits) can be read and saved through the API; business configuration (JMAP, Telegram, LLM, chat allowlist and the various auth tokens) is fully replaced through `PUT /api/business-config`. To avoid echoing secrets, the service exposes no read endpoint for business configuration, so every full replacement requires re-entering the mandatory fields. After a 204 response the page reports that the new configuration has been hot-loaded and clears the key inputs.
Redis 尚未连接或初始化时,管理会话或配置 API 会返回 `503`;页面会说明不可用状态,并在运行参数读取成功前禁用其保存。经 Cloudflare Worker 访问时使用 Worker 根地址;管理 API 路径也已纳入受限路由白名单。
While Redis is not connected or initialised, the admin-session and configuration APIs return `503`; the page explains the unavailable state and keeps runtime-parameter saving disabled until the read succeeds. When reached through a Cloudflare Worker, use the Worker's root URL; the admin API paths `/api/config`, `/api/business-config`, `/api/admin/session` and `/api/admin/session/revoke` are on the restricted route allowlist (`POST /api/push/register` is not — see `docs/deployment.md` §10.5).
## 隐私与运维红线(对用户透明)
- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。
- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。
- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。
- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。
## End-to-end first integration
This is a repeatable path from an empty deployment to a first message in Telegram. Every `<...>` below is a placeholder; do not copy the angle brackets, and never paste real secrets into chats, tickets or the repository.
### 1. Verify the deployment and Redis
```bash
curl -fsS https://<message-weave-domain>/api/status
curl -fsS https://<message-weave-domain>/healthz
```
With correct `REDIS_URL` and `CONFIG_ENCRYPTION_KEY`, `/api/status` should contain:
```json
{"ready":true,"mode":"configured","missing":[]}
```
If a variable is missing, the container does not exit; it enters `configuration-setup`, and the SPA shows only the missing variables without the admin-session section. The Redis URL must use the right scheme: `rediss://` for TLS, `redis://` for plain connections. A TLS Redis must not be verified only with `redis-cli --tls` — the application's own `REDIS_URL` must use `rediss://` too.
### 2. Create a Telegram bot and a test group
Open `@BotFather` in Telegram, send `/newbot`, follow the prompts and save the bot token. Then create a **group** (not a channel), e.g. `MessageWeave`, add the bot and make it an administrator.
Send `/start` in the group, then read the Chat ID from the Bot API's `getUpdates` endpoint:
```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getUpdates"
```
In the response:
```json
"chat":{"id":-5260770881,"title":"MessageWeave","type":"group"}
```
`-5260770881` is the Chat ID. Group IDs are usually negative — do not drop the minus sign. `getUpdates` is not a group-list endpoint; the group only appears once the bot has actually received a message in it. If it returns an empty array, send `/start` in the group first. If a webhook is already set, delete it first, otherwise messages are consumed by the webhook:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/deleteWebhook"
```
If the bot cannot see ordinary messages, run `/setprivacy` in `@BotFather`, choose the bot and select `Disable`; or make sure the bot is an administrator. Switch the webhook back on once debugging is done.
### 3. Generate the application-side auth secrets
Generate these three values separately. Do not reuse the Redis password, the bot token, or the SPA admin session:
```bash
openssl rand -hex 32 # Telegram Webhook Secret
openssl rand -hex 32 # Reconcile Token
openssl rand -hex 32 # Worker Token
```
- **Telegram Webhook Secret**: you generate it, submit it through the SPA, and pass it as `secret_token` to `setWebhook`. MessageWeave validates the `X-Telegram-Bot-Api-Secret-Token` request header.
- **Reconcile Token**: you generate it; the external cron uses it for `/reconcile`.
- **Worker Token**: you generate it; it protects `/worker` and the compatibility admin API.
### 4. Create the SPA admin session
Open `https://<message-weave-domain>/`, enter the password of the Redis ACL user from `REDIS_URL`, and click to create the admin session. It is valid for 900 seconds, lives only in the current page's memory, and must be created again after a refresh.
### 5. Fill in the Telegram business configuration
Example:
```text
Target Chat ID: -5260770881
Chat allowlist: -5260770881
Telegram Webhook Secret: <first openssl random value>
Reconcile Token: <second openssl random value>
Worker Token: <third openssl random value>
```
`Target Chat ID` and `Chat allowlist` are **not** the same field: the former is the default target, the latter is the whitelist of chats allowed to drive the bot. For a single test group they can hold the same ID. The allowlist accepts one entry per line or a comma-separated list; duplicates are rejected.
### 6. Fill in the JMAP business configuration
- The `JMAP Session URL` may be either `https://mail.example.com` or `https://mail.example.com/.well-known/jmap`; both are normalised.
- HTTPS only. Do not put a username, password, query string or fragment into the URL.
- `JMAP Username` is the mailbox account.
- `JMAP Password` is the Stalwart **App Password**.
- `Account ID` may be left empty for the first integration test; the session's primary account is used.
- You do not need to create a PushSubscription by hand in the Stalwart WebUI. The built-in `user` role normally already includes reading, creating, modifying and deleting PushSubscriptions; only if registration actually returns `forbidden` should an administrator check the role under `/admin → Management → Directory → Accounts/Roles`. Stalwart's Push global settings under `Settings → Network → JMAP → Push` are not a per-subscription configuration.
- Keep the LLM disabled for the first integration test, so you do not mix mailbox problems with AI network configuration.
### 7. Register the JMAP PushSubscription
MessageWeave does not require you to create a PushSubscription by hand in the Stalwart WebUI. After saving the JMAP business configuration, call the registration API with the current SPA admin session or `WORKER_TOKEN`, supplying a public HTTPS callback URL explicitly:
```bash
curl -X POST "https://<messageweave-domain>/api/push/register" \
-H "Authorization: Bearer <admin-session-or-worker-token>" \
-H "Content-Type: application/json" \
-d '{"callback_url":"https://<messageweave-domain>/push/jmap"}'
```
On success the response contains `push_subscription_id`. The URL must be HTTPS and must not contain a username, password or any other embedded credential. Re-registering the same callback URL reuses the existing subscription instead of creating a duplicate. Stalwart then sends its generated `PushVerification` to `/push/jmap`; MessageWeave writes the verification code back through JMAP `PushSubscription/set`, and only accepts real Push events once verification has succeeded. The registration API never guesses the platform domain and never accepts a user-entered verification code.
The regular `user` role normally already has read/create/modify/delete permissions on PushSubscriptions, so no manual configuration is needed; only check `/admin → Management → Directory → Accounts/Roles` if you actually get `forbidden`. Stalwart's `Settings → Network → JMAP → Push` page holds global Push parameters, not per-subscription creation.
References: [Stalwart Push notifications](https://stalw.art/docs/http/jmap/push/), [Stalwart Permissions](https://stalw.art/docs/auth/authorization/permissions/), [Stalwart Roles](https://stalw.art/docs/auth/authorization/roles/), [RFC 8620 §7.2 PushSubscription](https://www.rfc-editor.org/rfc/rfc8620.html).
When the business configuration is saved the service really instantiates the JMAP client; if that fails it returns `503`, stores no broken configuration, and you can fix and re-save. There is no GET endpoint for business configuration, so after a successful save the keys are not echoed back; every full replacement requires re-entering the mandatory fields.
### 8. Set the Telegram webhook and enable business processing
After the business configuration is saved:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
--data-urlencode "url=https://<message-weave-domain>/webhook/tg" \
--data-urlencode "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
```
Make sure the returned `url` is correct and that `last_error_message` does not keep growing. Then go back to the SPA and switch on "enable business processing". While the global switch is off, the webhook, reconcile and worker routes return `503 service disabled` and neither enqueue nor ack upstream messages.
Finally send a test message in the allowlisted group. When it arrives, watch Telegram `getWebhookInfo`, the platform logs and the bot's reply. If there is no reply, check the Chat ID's minus sign, the allowlist, the Webhook Secret, the global switch, and the bot's admin rights first.
### 9. Test reconcile authentication
```bash
curl -i -X POST "https://<message-weave-domain>/reconcile" \
-H "Authorization: Bearer <RECONCILE_TOKEN>"
```
A wrong token must return `401`. The external cron is recommended to call once every 5–10 minutes, as a safety net for email reconciliation when Push events are lost.
## How email synchronisation works today
Do not treat PushSubscription as the only reliable source in the current release. Push callbacks still have to be authenticated and enqueued, so run the external cron calling `/reconcile` at the same time. Reconciliation uses a persistent Redis `sinceState` and returns `503` on enqueue failure instead of advancing the cursor early.
If your platform lets the service sleep, the cron's HTTP request will usually wake the container, but the first request pays the cold-start cost and may exceed the cron's or the platform's request timeout. Therefore:
- set the cron timeout long enough and allow retries on failure;
- do not call more often than the platform's wake-up / request limits allow;
- do not use `/healthz` as email synchronisation — it only checks that the process is alive;
- if the service cannot be woken while sleeping, reconciliation cannot run either; in that case disable sleeping or use an always-on instance.
The reconciliation lock is renewed by its holder while the task runs; if the renewal fails the cursor is not advanced. Push registration and reconciliation both use Redis owner-token single-flight locks, so concurrent requests cannot create duplicate subscriptions or advance the same JMAP cursor twice.
## Troubleshooting
### Redis: `can't connect with TLS, the feature is not enabled`
Usually the Redis TLS feature was not enabled when the image was built, or `redis://` / `rediss://` is wrong. Use the latest image with TLS support and make sure `REDIS_URL=rediss://...`. Special characters in an Upstash password must be URL-encoded, e.g. `@` becomes `%40`.
### Redis: `Multiplexed connection driver unexpectedly terminated`
First validate the network and credentials with the same URL from outside. Do not validate only `redis-cli --tls` while the application still uses `redis://`. Check that HostStack / the platform allows outbound 6379/TLS, and check the ACL user, password and port in the URL.
### rustls provider panic
This is the old image loading more than one TLS provider. You must use the latest image, which installs the explicit rustls `ring` provider; do not keep using cached old image tags.
### `getUpdates` returns an empty array
The bot has received no new messages, or the message was already consumed by the webhook. Delete the webhook and send `/start` in the group again; confirm the bot is in the group, is an administrator, and — if needed — that privacy mode is disabled through BotFather.
### Telegram says it is not allowed to read messages
Confirm that you created a **group** and not a channel; make the bot an administrator; send `/start` again. With privacy mode enabled, the bot normally only sees commands, replies and mentions.
### SPA returns `503` when saving business configuration
This usually means the JMAP URL is unreachable from the deployment platform, the certificate/DNS is broken, the app password is wrong, or the LLM configuration fails HTTPS validation. A failed save keeps the old worker and the old configuration, and does not lock the initialisation slot; fix and retry.
### SPA shows *configuration-setup*
Check that the HostStack Secrets or container environment contain both:
```text
REDIS_URL
CONFIG_ENCRYPTION_KEY
```
`CONFIG_ENCRYPTION_KEY` must be a 32-byte hex value, e.g.:
```bash
openssl rand -hex 32
```
It must never be written into the public `hoststack.yaml`, the Dockerfile, the README or the image.
## Privacy and operational red lines (transparent to users)
- **No database, no local files**: all of the bot's state lives in your Redis. The container writes no local files and mounts no local volumes; after a restart it recovers from Redis plus mailbox reconciliation.
- **Logs go to stdout only**: the container prints logs to stdout/stderr, and your platform or collector is responsible for collection and storage. The bot **does not write log files**.
- **Sensitive content never lands in logs**: mailbox bodies, keys, AI requests/responses and attachment contents are **never** written to logs or Redis. Only structured events, counters, timestamps and redacted summaries are recorded.
- See the checklists in `docs/deployment.md` §0 and §8.2.
---
## 目前能做到什么 / 还差什么
## What it can do today / what is still missing
**已经有了**
- 入口框架、三处安全鉴权、配置加载。
- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。
- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。
- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。
**Already in place**
**还差 / 尚未验证**
- ⚠️ **真实邮箱联调没做**:连真实 Stalwart 的测试因还没有测试环境而跳过,**没有宣称“真机通过”**。
- **Dockerfile 已提供**(Debian slim 多阶段构建);真实邮箱联调仍未验证,不能按上面的“创建容器”用于生产。
- Telegram 通知推送、AI 总结、发送邮件等功能**都还没做**。
- The entry-point framework, three layers of auth protection, and configuration loading.
- A configuration SPA backed by short-lived Redis admin sessions: the full business configuration can be edited and hot-loaded, and `/api/config` runtime parameters can be read and saved.
- Basic code for reading your Stalwart mailbox (list folders, list emails, read raw messages).
- Telegram webhook, Chat ID allowlist, Reconcile / Worker bearer tokens, and a Redis-persisted global enable switch.
- HostStack native Rust deployment configuration and a Docker build.
- **Optional**: a free Cloudflare Worker front-load-balancer for multi-instance HA — see `docs/deployment.md` §10 and the `cloudflare-worker/` directory.
所以:**现在还没有可以下载使用的镜像,请自行构建并等待后续版本。**
**Still missing / not yet verified**
- ⚠️ **Real mailbox integration needs to be verified by the operator**: the JMAP client code and configuration validation are provided, but different Stalwart networks, permissions and app passwords still differ.
- **JMAP Push registration is implemented**: after an administrator submits a public HTTPS callback URL through `POST /api/push/register`, the backend calls `PushSubscription/set create` and `/push/jmap` writes the verification code back automatically. A real Stalwart environment still needs to be verified, and the external cron calling `/reconcile` remains the reliable compensation channel.
- Production-grade multi-instance coordination and sending email are not finished yet; AI summarisation is optional and needs separate configuration and verification.
Both a Docker image and the HostStack native Rust deployment are provided, but we still recommend running the integration steps above on a small scale before using it in production.
## Quality gates for contributors
Everything must be green before a change is released (run inside the Debian `rust:1-slim-bookworm` container — see `AGENTS.md` §5 and `docs/deployment.md` §8):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
- Real-device verification tests are marked `#[ignore]` and driven by environment variables (`cargo test -- --ignored jmap::`); CI does not run them by default. Do not claim "verified on real hardware" before actually running them.
- Any change that touches a security boundary must be reflected consistently in all three of `docs/design.md`, `docs/deployment.md` and `AGENTS.md`.
---
## 给开发/技术人员
## For developers
架构、设计、部署细节见:
Architecture, design and deployment details:
- [架构与设计](docs/design.md)
- [部署与运维](docs/deployment.md)
- [给 AI 编码助手的规则](AGENTS.md)
- [Architecture & design](docs/design.md)
- [Deployment & operations](docs/deployment.md)
- [Rules for AI coding assistants](AGENTS.md)
- [Open items and blockers](docs/todo.md)
## 许可证
## License
(待定)
(TBD)

323
README.zh-CN.md Normal file
View File

@ -0,0 +1,323 @@
# MessageWeave(中文)
English documentation: [README.md](README.md) · 架构与设计见 [docs/design.md](docs/design.md),部署与运维见 [docs/deployment.md](docs/deployment.md)
一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。
> ⚠️ **当前仍属早期阶段,请先按本文完成小范围联调。**
> 真实邮箱和 Telegram 仍需要由部署者自行验证;JMAP Push 注册、验证回写已接入,但不会自动猜测公网 callback URL,必须由管理员显式调用注册接口。
---
## 这个项目是什么
- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。
- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。
- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。
### 隐私原则
- **查看原文不会经过任何 AI**,直接读邮箱。
- **只有在你明确要求并确认后**,才把正文发给 AI 分析。
- 分析结果不会被保存。
- AI 可以完全不配置、完全关闭。
---
## 部署前,你需要准备这几样东西
1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。
2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。
3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。TLS Redis 使用 `rediss://`,普通 Redis 使用 `redis://`;Upstash 等服务的密码如果包含 `@`、`:`、`/` 或 `#`,必须先做 URL 编码。
4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。
5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。
### 需要登记的地址
- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg`
> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面的 Telegram Webhook 和 Reconcile 地址指向 **Worker 的稳定域名**(`https://<your-worker>.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。
---
## 在 Docker 管理器中创建容器
创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据:
| 环境变量 | 必须? | 填什么 |
|---|---|---|
| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` |
| `CONFIG_ENCRYPTION_KEY` | ✔ | 32 字节十六进制密钥;可用 `openssl rand -hex 32` 生成 |
首次启动后,通过 SPA 的受保护配置页面提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥加密写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。
AI 摘要默认关闭。Telegram 用户需先发送受支持的授权语,再发送 `/summary <email_id>`:`/ai on`(或 `/ai yes`,1 小时);含 `临时` 或 `一次` 的任意消息,如 `临时一次`(一次性,1 小时);`今天`(24 小时);`7天`(7 天);`直到我撤销`(或 `长期`,最长 365 天)。这些触发词按原文字匹配,需按上文的中文原文输入,不支持英文别名。撤销:`/ai off`(或 `撤销授权`)。授权到期不会自动续期。未授权时仅返回邮件元数据;未配置 AI 或调用失败则回退为本地截断摘要。
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
### HostStack 原生部署
HostStack 使用根目录 `hoststack.yaml` 构建并运行 Rust 服务:执行 `cargo build --release --locked`,运行 `./target/release/message-weave`,并以 `/healthz` 进行 30 秒间隔、5 秒超时的健康检查。请在 HostStack Secret 中注入 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;密钥不写入 YAML、镜像或仓库文件。
管理会话内的“启用业务处理”开关持久化在 Redis `config:enabled`;缺失或读取失败均按关闭处理。关闭时 Webhook、Reconcile 和 Worker 返回 HTTP 503,不确认或丢弃上游事件;SPA、状态和管理配置 API 仍可用。
---
## 启动后怎么看它是否正常
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
- `/ready` 返回 JSON 就绪状态并检查配置完整性与 Redis 可访问性;200 表示基础依赖就绪,503 表示不应接收业务流量。它不会调用 JMAP、Telegram 或触发任何业务副作用。
- 运行监控建议使用 Uptime Kuma:为 `/healthz` 建立存活 HTTP 检查,为 `/ready` 建立就绪 HTTP 检查(期望 200,间隔 30 秒、超时 5 秒)。项目不引入 Prometheus 或 exporter。
- `/reconcile` 执行鉴权、Redis 单飞锁与有界 JMAP `Email/changes` 对账;事件先幂等入 Redis Streams,全部成功后才推进 Redis `sinceState`,依赖暂时失败返回 `503` 以便 Cron 重试。Push 事件仍按鉴权、去重、入队路径处理,不能将其宣传为完整实时同步。
- 冷启动基线也有页数、邮件数和时间预算;超出预算时保存断点,下一次 `/reconcile` 从断点继续,避免大邮箱反复从头扫描。
## 配置管理
打开服务根地址 **`你的公网地址/`**,输入 `REDIS_URL` 中 Redis ACL 用户的密码,创建有效期 15 分钟的管理会话。会话与表单中的密钥只保存在当前页面内存;刷新或关闭页面后需要重新授权。页面不使用浏览器存储或 Cookie 保存会话、密钥和配置。
页面分开管理两类配置:运行参数(JMAP、Telegram、LLM 请求超时与最大重试次数)可通过 API 读取和保存;业务配置(JMAP、Telegram、LLM、chat allowlist 与各鉴权 token)由 `PUT /api/business-config` 完整替换。为避免密钥回显,服务端没有业务配置读取接口,因此每次完整替换都必须重新输入必填项。保存收到 HTTP 204 后页面会提示新配置已热加载,并清空密钥输入框。
Redis 尚未连接或初始化时,管理会话或配置 API 会返回 `503`;页面会说明不可用状态,并在运行参数读取成功前禁用其保存。经 Cloudflare Worker 访问时使用 Worker 根地址;`/api/config`、`/api/business-config`、`/api/admin/session`、`/api/admin/session/revoke` 等管理 API 路径已纳入受限路由白名单(`POST /api/push/register` 不在其中,需按 `docs/deployment.md` §10.5 直连某个后端实例)。
## 首次联调完整流程
下面是一条从空部署到 Telegram 收到第一条消息的可重复流程。所有示例中的 `<...>` 都是占位符,不要把尖括号原样复制;真实密钥不要发到聊天、工单或仓库。
### 1. 先确认部署和 Redis
访问:
```bash
curl -fsS https://<message-weave-domain>/api/status
curl -fsS https://<message-weave-domain>/healthz
```
当 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY` 正确时,`/api/status` 应包含:
```json
{"ready":true,"mode":"configured","missing":[]}
```
如果缺少变量,容器不会退出,而是进入 `configuration-setup`;SPA 只显示缺少的变量,不会显示管理会话授权区。Redis URL 必须包含正确协议:`rediss://` 表示 TLS,`redis://` 表示明文连接。TLS Redis 不能只在 `redis-cli` 命令中加 `--tls`,应用本身的 `REDIS_URL` 也必须使用 `rediss://`。
### 2. 创建 Telegram Bot 和测试群
在 Telegram 中打开 `@BotFather`,发送 `/newbot`,按提示创建机器人并保存 Bot Token。然后新建“群组”(不是频道),例如 `MessageWeave`,把机器人加入群并设置为管理员。
在群里发送 `/start`,然后临时使用 Bot API 的 `getUpdates` 查看 Chat ID:
```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getUpdates"
```
返回中的:
```json
"chat":{"id":-5260770881,"title":"MessageWeave","type":"group"}
```
其中 `-5260770881` 就是 Chat ID。群 ID 通常是负数,负号不能删除。`getUpdates` 不是群组列表接口;只有机器人实际收到消息后才会返回该群。若返回空数组,先在群里发送 `/start`。若已经设置 Webhook,先删除它,否则消息会被 Webhook 消费:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/deleteWebhook"
```
如果机器人看不到普通消息,在 `@BotFather` 执行 `/setprivacy`,选择机器人并选择 `Disable`;或者确保机器人是管理员。调试结束后再设置 Webhook。
### 3. 生成应用侧鉴权密钥
以下三个值必须分别生成,不能使用 Redis 密码、Bot Token 或 SPA 管理会话:
```bash
openssl rand -hex 32 # Telegram Webhook Secret
openssl rand -hex 32 # Reconcile Token
openssl rand -hex 32 # Worker Token
```
- **Telegram Webhook Secret**:由你生成,填写 SPA,并在 Telegram `setWebhook` 时作为 `secret_token`。MessageWeave 会校验请求头 `X-Telegram-Bot-Api-Secret-Token`。
- **Reconcile Token**:由你生成,供外部 Cron 调用 `/reconcile`。
- **Worker Token**:由你生成,供 `/worker` 和兼容管理 API 使用。
### 4. 创建 SPA 管理会话
打开 `https://<message-weave-domain>/`,输入部署时 `REDIS_URL` 中 Redis ACL 用户的密码,点击创建管理会话。会话有效期 900 秒,只保存在当前页面内存,刷新页面后需要重新授权。
### 5. 填写 Telegram 业务配置
示例:
```text
目标 Chat ID: -5260770881
Chat allowlist: -5260770881
Telegram Webhook Secret: <第一组 openssl 随机值>
Reconcile Token: <第二组 openssl 随机值>
Worker Token: <第三组 openssl 随机值>
```
`目标 Chat ID` 和 `Chat allowlist` 不代表同一个字段:前者是默认目标,后者是允许触发机器人的白名单;测试单个群时可以填写相同的 ID。allowlist 支持每行一个或逗号分隔,重复项会被拒绝。
### 6. 填写 JMAP 业务配置
- `JMAP Session URL` 可填 `https://mail.example.com` 或 `https://mail.example.com/.well-known/jmap`;两者会被归一化。
- 只能使用 HTTPS;URL 中不要放用户名、密码、query 或 fragment。
- `JMAP Username` 填邮箱账号。
- `JMAP Password` 填 Stalwart 应用专用密码。
- `Account ID` 初次联调可留空,使用 JMAP Session 的主账户。
- 不需要在 Stalwart WebUI 手工创建 PushSubscription。通常内置 `user` 角色已经包含 PushSubscription 的读取、创建、修改和删除权限;只有实际注册返回 `forbidden` 时,管理员才需要在 `/admin → Management → Directory → Accounts/Roles` 检查角色。Stalwart 的 Push 全局参数位于 `Settings → Network → JMAP → Push`,它不是具体订阅配置。
- LLM 初次联调建议关闭,避免把邮件问题和 AI 网络配置混在一起。
### 7. 注册 JMAP PushSubscription
MessageWeave 不要求你在 Stalwart WebUI 中手工创建 PushSubscription。保存 JMAP 业务配置后,使用当前 SPA 管理会话或 `WORKER_TOKEN` 调用注册接口,并明确提供公网 HTTPS callback URL:
```bash
curl -X POST "https://<messageweave-domain>/api/push/register" \
-H "Authorization: Bearer <admin-session-or-worker-token>" \
-H "Content-Type: application/json" \
-d '{"callback_url":"https://<messageweave-domain>/push/jmap"}'
```
成功返回 `push_subscription_id`。URL 必须是 HTTPS,不能包含用户名、密码或其他内嵌凭据。相同 callback URL 的重复注册请求会复用已有订阅,避免重复创建。Stalwart 随后向 `/push/jmap` 发送它生成的 `PushVerification`;MessageWeave 自动通过 JMAP `PushSubscription/set` 回写验证码,验证成功后才接收正式 Push。注册接口不会猜测平台域名,也不会接受用户填写验证码。
普通 `user` 角色通常已经具备 PushSubscription 的读取、创建、修改和删除权限,无需手工配置;只有实际返回 `forbidden` 时,管理员才检查 `/admin → Management → Directory → Accounts/Roles`。Stalwart 的 `Settings → Network → JMAP → Push` 是全局 Push 参数,不是具体订阅创建页面。
依据:[Stalwart Push notifications](https://stalw.art/docs/http/jmap/push/)、[Stalwart Permissions](https://stalw.art/docs/auth/authorization/permissions/)、[Stalwart Roles](https://stalw.art/docs/auth/authorization/roles/)、[RFC 8620 §7.2 PushSubscription](https://www.rfc-editor.org/rfc/rfc8620.html)。
保存业务配置时服务端会真实建立 JMAP 客户端;建立失败返回 `503`,不会写入坏配置,可以直接修正后再次保存。业务配置没有 GET 接口,保存成功后密钥不会回显;完整替换时必须重新输入必填项。
### 8. 设置 Telegram Webhook 并打开业务开关
业务配置保存成功后执行:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
--data-urlencode "url=https://<message-weave-domain>/webhook/tg" \
--data-urlencode "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
```
确认返回的 `url` 正确,并且没有持续增长的 `last_error_message`。然后回到 SPA 打开“启用业务处理”。全局开关关闭时,Webhook、Reconcile 和 Worker 都返回 `503 service disabled`,不会入队,也不会 ACK 上游消息。
最后在白名单群里发送测试消息。收到消息后观察 Telegram `getWebhookInfo`、平台日志和机器人回复;如果没有回复,优先检查 Chat ID 的负号、allowlist、Webhook Secret、全局开关和机器人管理员权限。
### 9. Reconcile 鉴权测试
```bash
curl -i -X POST "https://<message-weave-domain>/reconcile" \
-H "Authorization: Bearer <RECONCILE_TOKEN>"
```
错误 Token 必须返回 `401`。外部 Cron 建议每 5–10 分钟调用一次,用于 Push 丢失时的邮件对账兜底。
## 当前邮件同步方式
当前版本不要把 PushSubscription 当作唯一可靠来源;Push 回调仍需鉴权后入队,邮件同步请同时使用外部 Cron 定时调用 `/reconcile`。对账使用 Redis 持久 `sinceState`,并在入队失败时返回 `503`,不会提前推进游标。
如果部署平台会让服务休眠,Cron 的 HTTP 请求通常可以唤醒容器,但首次请求会承担冷启动延迟,可能超出 Cron 或平台的请求超时时间。因此:
- 将 Cron 超时时间设置得足够长,并允许失败重试;
- 频率不要高于平台允许的唤醒/请求限制;
- 不要把 `/healthz` 当作邮件同步,它只检查进程存活;
- 如果平台休眠期间无法唤醒服务,Reconcile 也无法执行,此时需要关闭休眠或改用常驻实例。
对账锁会在长任务运行期间由持有者续租;续租失败不会推进游标。Push 注册和对账都使用 Redis owner-token 单飞锁,避免并发请求重复创建订阅或并行推进同一个 JMAP 游标。
## 常见踩坑与排查
### Redis 报 `can't connect with TLS, the feature is not enabled`
通常是镜像构建时 Redis TLS feature 未启用,或 `redis://` / `rediss://` 写错。使用包含 TLS 支持的最新镜像,并确保 `REDIS_URL=rediss://...`。Upstash 密码中的特殊字符必须 URL 编码,例如 `@` 写成 `%40`。
### Redis 报 `Multiplexed connection driver unexpectedly terminated`
先用同一个 URL 在外部验证网络和凭据;不要只验证 `redis-cli --tls` 而应用仍使用 `redis://`。检查 HostStack/平台是否允许出站 6379/TLS,检查 URL 中 ACL 用户、密码和端口。
### Rustls 报 provider panic
这是旧镜像同时加载多个 TLS provider 的问题。必须使用包含显式 rustls `ring` provider 安装逻辑的最新镜像,不要继续使用旧镜像标签缓存。
### `getUpdates` 返回空数组
机器人没有收到新消息,或消息已经被 Webhook 消费。删除 Webhook 后在群里重新发送 `/start`;确认机器人已加入群、是管理员,并在需要时通过 BotFather 关闭隐私模式。
### Telegram 提示无权读取消息
确认创建的是群组而不是频道;把机器人设为管理员;重新发送 `/start`。隐私模式开启时,机器人通常只能看到命令、回复和提及它的消息。
### SPA 保存业务配置返回 `503`
这通常表示 JMAP URL 无法从部署平台访问、证书/DNS 有问题、应用密码错误,或 LLM 配置不符合 HTTPS 校验。保存失败会保留旧 worker 和旧配置,不会锁死初始化槽位;修正后可重试。
### SPA 显示 configuration-setup
检查 HostStack Secret 或容器环境变量中是否同时存在:
```text
REDIS_URL
CONFIG_ENCRYPTION_KEY
```
`CONFIG_ENCRYPTION_KEY` 必须是 32 字节十六进制值,例如:
```bash
openssl rand -hex 32
```
它不能写在公开的 `hoststack.yaml`、Dockerfile、README 或镜像中。
## 隐私与运维红线(对用户透明)
- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。
- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。
- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。
- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。
---
## 目前能做到什么 / 还差什么
**已经有了**
- 入口框架、三处安全鉴权、配置加载。
- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。
- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。
- Telegram Webhook、Chat ID 白名单、Reconcile/Worker Bearer Token 和 Redis 持久化全局启用开关。
- HostStack 原生 Rust 部署配置与 Docker 构建配置。
- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。
**还差 / 尚未验证**
- ⚠️ **真实邮箱联调需要部署者验证**:JMAP 客户端代码和配置校验已提供,但不同 Stalwart 网络、权限和应用密码仍可能不同。
- **JMAP Push 注册已接入**:通过 `POST /api/push/register` 显式提交 HTTPS callback URL 后,后端调用 `PushSubscription/set create`,并由 `/push/jmap` 自动完成验证码回写。当前仍需真实 Stalwart 环境验证;外部 Cron 调用 `/reconcile` 继续作为可靠补偿通道。
- 生产级多实例协调和发送邮件能力仍未完成;AI 总结属于可选能力,需另行配置并验证。
Docker 镜像和 HostStack 原生 Rust 部署均已提供,但仍建议先按本文完成小范围联调,再考虑生产使用。
## 对开发者的门禁
任何改动发布前须全绿(在 Debian `rust:1-slim-bookworm` 容器内执行,见 `AGENTS.md` §5 与 `docs/deployment.md` §8):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
- 真机验证用例以 `#[ignore]` 标记、由环境变量驱动(`cargo test -- --ignored jmap::`),CI 默认不跑;未实际运行前不得声称“真机通过”。
- 涉及安全边界的改动需同步 `docs/design.md`、`docs/deployment.md`、`AGENTS.md` 三份文档。
---
## 给开发/技术人员
架构、设计、部署细节见:
- [架构与设计](docs/design.md)
- [部署与运维](docs/deployment.md)
- [给 AI 编码助手的规则](AGENTS.md)
- [未完成项与阻塞原因](docs/todo.md)
## 许可证
(待定)

View File

@ -60,7 +60,7 @@
### 3.1 构建阶段
(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 `message-weave`)
> **实现现状**(阶段0 + 阶段1,`ARCH-STAGE0`/`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):自动依赖含 `axum 0.8`(单端口入口)/`serde`/`serde_json`/`thiserror`/`secrecy`/`subtle`(常数时间比较)/`url`/`tokio`/`tracing` 等;**`jmap-client =0.4.2` 已引入**(阶段1,`default-features=false, features=["async","rustls"]`,**禁用 WebSocket feature**,`C-NO-LONG-CONN`);三条入口路由的鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`),**JMAP 只读 adapter(G1/D-G1-1)代码已实现、待真实 `cargo test -- --ignored jmap::` 验证**,其余业务体仍为**占位**;`teloxide`(阶段2)/`redis`(阶段3/4)/`reqwest`(阶段3.5) 尚未引入;配置为环境变量手工解析(`ARCH-CONFIG-ENV`,无 figment/TOML)。CI 门禁见 §8(`cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test`,在 Debian 容器内,`GATE-P0`)。
> **实现现状**:单端口 axum 入口、`jmap-client =0.4.2` 只读 adapter、Redis Streams、Telegram Webhook、JMAP Push 入队和 `/reconcile` 已接入;三条入口路由鉴权 fail-closed 落地(`R1`/`SAF-AUTH-*`)。本地 Debian 容器门禁已通过;真实 Stalwart、Redis、Telegram 的端到端链路仍需部署后验证。配置引导模式会在启动变量缺失时保持 HTTP/SPA 可访问,不伪造业务成功。
### 3.2 运行阶段(收敛为单端口)
- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口)
@ -73,8 +73,8 @@
## 4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口
- 非 root 用户运行(保留既有约定)
- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、/push/jmap、/reconcile 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。
- 需要在 Stalwart 与 Telegram 两侧登记这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;Stalwart PushSubscription 的 `url` 指向 `/push/jmap`。
- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、`/push/jmap`、`/reconcile` 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。
- 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;管理员调用 `POST /api/push/register` 并提交该 URL 的 `/push/jmap` 路径,后端负责执行 `PushSubscription/set create`。服务不会自动猜测平台公网域名。
- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。
- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)
@ -95,7 +95,7 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
`/reconcile`、`/worker` 返回 503,不执行入队、ACK 或业务处理;SPA、`/api/status`、管理
会话和配置 API 保持可用,打开后立即生效。
> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明,不应再注入生产容器。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。
> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明(走 `Config::from_env()` 环境变量引导路径时),**不应再注入生产容器**,表内 ✅ 也不代表当前生产必须配置。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。
(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽;新增运行模式相关变量)
@ -104,7 +104,7 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
| `BOT_TOKEN` | ✅ | Telegram Bot Token |
| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) |
| `JMAP_SESSION_URL` | ✅ | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):填**服务基地址**(`https://mail.example.com`)或**完整** `…/.well-known/jmap` 均可;代码归一化为 origin/base 后再交 `jmap-client`,**不产生重复路径**。仅 HTTPS;**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth |
| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth。通常内置 `user` 角色已包含 PushSubscription 的读取/创建/修改/删除权限,无需手工配置;若实际调用返回 `forbidden`,再到 `/admin → Management → Directory → Accounts/Roles` 检查 |
| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 |
| `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;支持 `redis://` 与带默认 ACL 用户的 `rediss://default:<url-encoded-password>@host:6379/0`,Redis 进程不在此 compose 内 |
| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) |
@ -112,12 +112,12 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查
| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) |
| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session |
| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) |
| `JMAP_PUSH_VERIFICATION` | ✅ | `/push/jmap` 校验请求体 JSON 字段 `verificationCode`(`SAF-AUTH-JMAP-PUSH`)。须与 Stalwart PushSubscription 的 verification code **完全一致**(`SecretString`) |
| Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 |
| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
> 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
@ -178,15 +178,16 @@ GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错
PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204
POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token]
→ 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径)
POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body verificationCode]
POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body pushSubscriptionId + verificationCode]
→ 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步)
POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
→ 对账补差 FLOW-RECONCILE
GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER)——**不执行** Redis PING / JMAP session / TG getMe;真实 readiness 属后续门禁 GATE-READY-DEPS(公开探针,无鉴权、无敏感信息)
GET /ready 公开 JSON 就绪探针;检查配置完整性与 Redis 可访问性,未就绪返回 503(无鉴权、无敏感信息)
```
> **三入口鉴权(fail-closed,`SAF-AUTH-*`)**:三条写路径必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**;**`/ready` 现阶段不检查任何依赖**,不得据此判断 Redis/JMAP/TG 可用性(见 §7 与 design.md §10.0 `GATE-READY-DEPS`)。
`/reconcile` 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。
> **写入口鉴权(fail-closed,`SAF-AUTH-*`)**:Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**。`/healthz` 只表示进程存活;`/ready` 检查配置完整性与 Redis 可访问性,不发起 JMAP/Telegram 请求,也不触发邮件同步或其他副作用。
> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。
### 6.2 Redis Streams / worker(MOD-STREAMS)
@ -196,8 +197,7 @@ GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。
由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`),触发一次对账。
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。
- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
@ -212,8 +212,8 @@ GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER
| **Streams ACK / 重试** | `MOD-STREAMS` | worker 用消费组 `XREADGROUP`;处理成功才 `XACK`;未 ACK 消息在被认领后重投(at-least-once)。处理失败时**不 ACK**,自然重试;设最大投递次数上限,超限转死信(`XADD` 到 `dlq`)并告警,避免毒丸阻塞 |
| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` |
| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 |
| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟拉 `Email/changes` 补差,覆盖 Push 丢失/Redis 抖动/冷启动窗口;同时用于 Redis 全丢后的游标重建 |
| **指标 / 告警** | — | 导出指标:Push 到达数、去重命中率、Streams 积压深度(pending)、DLQ 条数、对账补发条数、JMAP 延迟、TG 推送失败率。对"积压持续增长 / DLQ 非空 / 对账补发异常升高"告警。**注**:`/ready` 现为占位 200(`ARCH-READY-PLACEHOLDER`),**尚不反映依赖健康**;待 `GATE-READY-DEPS` 落地后才可用于依赖告警 |
| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟调用 `/reconcile`;从 Redis 恢复 `state:jmap:since`,按 `Email/changes` 补差,入队成功后推进游标,失败返回 `503` |
| **运行监控 / 告警** | Uptime Kuma | 使用 HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。应用不引入 Prometheus、Exporter 或额外指标端口。 |
### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)
@ -227,9 +227,9 @@ GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER
## 7. 健康检查(通用 HTTP 约定)
- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义)
- `GET /ready` → **阶段0 为占位 `200`**(`ARCH-READY-PLACEHOLDER`),**不做** Redis PING / JMAP session / TG getMe。真实 readiness 检查(依赖不可用 → `503`)为**后续门禁 `GATE-READY-DEPS`**,随 `redis` 接入且 JMAP 只读 adapter 可用后实现。
- `GET /ready` → 基础就绪探针(`ARCH-READY-BASELINE`):检查配置完整性与 Redis 可访问性,不就绪返回 `503`;**不代表** Redis/JMAP/TG 依赖已通过端到端检查。
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
- ⚠️ 在 `GATE-READY-DEPS` 落地前,**不得**把 `/ready` 描述/当作"依赖健康检查",编排探针亦不得依赖其探测依赖。
- `/ready` 已检查配置完整性与 Redis 可访问性,但不代表 JMAP/Telegram 已完成真实端到端验收;编排与 Uptime Kuma 可用它做基础就绪探测。
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)
- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`)
@ -244,6 +244,8 @@ GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER
以上三条**在 Debian `rust:1-slim-bookworm` 容器内执行**(`C-DEBIAN-SLIM`);详见 design.md §10.0。
容器以 `--user 1000:1000` 运行,并把 `RUSTUP_HOME`/`CARGO_HOME` 挂载到仓库内持久缓存目录(`-v "$PWD/.gate-cache/rustup:$RUSTUP_HOME" -v "$PWD/.gate-cache/cargo:$CARGO_HOME"`),否则缓存写入因权限失败、工具链每次重建;`.gate-cache/` 已在 `.gitignore` 中,不得提交。门禁结果必须基于**当前仓库快照**重跑得到,不得复用先前快照的通过结果。
(其余 CI:镜像构建 → 镜像扫描 → 非 root → health 端点到容器验证;compose 示例收敛为单端口 + 外部 Redis URL + 无 volume)
### 8.1 docker-compose 示例(收敛)
@ -257,7 +259,7 @@ services:
PORT: "8080"
RUN_MODE: webhook
# BOT_TOKEN / JMAP_* / CHAT_ALLOWLIST / REDIS_URL
# RECONCILE_TOKEN / TG_WEBHOOK_SECRET / JMAP_PUSH_VERIFICATION(三入口鉴权,均为必填 SAF-AUTH-*)
# RECONCILE_TOKEN / TG_WEBHOOK_SECRET(写入口鉴权;Push verification 由 Stalwart 动态生成)
healthcheck: { test: ["CMD", "message-weave", "health", "--addr", "127.0.0.1:8080"], interval: 30s }
# REDIS_URL 指向外部、已认证的 Redis;不在此 compose 中运行 Redis。
```
@ -275,7 +277,7 @@ services:
| 3 | **日志仅 stdout/stderr**:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 `stdout` | `C-LOG-STDOUT-ONLY` | 检查代码:`grep -rEn 'rolling\|FileAppender\|RollingFileAppender' src/` 应为空;`RUST_LOG` 目标不含 `file:` |
| 4 | **日志/Redis 无敏感数据**:日志字段白名单 + CI 扫描 | `SAF-LOG-PURITY` | 代码评审 + 静态扫描(如 `gitleaks` 扫描 `Redis` 写入调用,验证 `SETEX`/`SET` 参数无密钥/正文/AI 内容) |
| 5 | **重启恢复不依赖进程内状态**:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 | `C-NO-STATEFUL-RECOVERY` | 代码评审:`grep -rn 'static mut\|lazy_static' src/` 不得用于生产恢复路径 |
| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm <image> sh -c 'command -v redis-server && exit 1 || exit 0'` 应为 0 |
| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm <image> sh -c 'command -v redis-server && exit 1 \|\| exit 0'` 应为 0 |
> 任一检查失败,禁止发布。
@ -346,15 +348,15 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.3 信任模型 = **透传(A)**(`SAF-LB-PASSTHRU`)
- Worker **原样转发** header / body / `verificationCode`,**不做鉴权改写**。
- Worker **原样转发** header / body / `pushSubscriptionId` / `verificationCode`,**不做鉴权改写**。
- **后端必须继续 fail-closed 校验**三条入口(`SAF-AUTH-*`)——**不可省**。原因:小平台通常**不提供防火墙/ACL**,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景**不可行**)。
- **多实例必须共享同一组 secret**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` / `RECONCILE_TOKEN` 在所有实例上**完全一致**,否则请求落到不同实例会随机 `401`(对端发来的值只有一个)。
- **多实例必须共享业务鉴权配置**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `RECONCILE_TOKEN` 在所有实例上完全一致;Push verification 状态由 Redis 共享。
### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**`POST /api/push/register` 不在 safelist 内**(Worker 对其返回 404),Push 注册必须按 §10.5 直接访问某个后端实例地址,不能走 Worker 域名。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,检查后端配置与 Redis 基础就绪,但不触发 JMAP/Telegram 副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。
@ -432,7 +434,7 @@ curl https://<your-worker>.workers.dev/healthz
**边界说明**
- Worker **未**实现 `/reconcile` 的 Redis 锁(`SAF-RECONCILE-LOCK`)——该锁由**后端**在 `/reconcile` 处理器内部执行;Worker 只做透传。详见 `SAF-RECONCILE-LOCK` 条目。
- Worker **不代理** Redis、不检查数据库侧可用性(`C-NO-DB` / `C-REDIS-ONLY-STATE`)。
- Worker **不落地** JMAP/Telegram 的 secret(`TELEGRAM_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION` 等属于**后端**容器,见 `deployment.md §5` 与 `.env.example`)。
- Worker **不落地** JMAP/Telegram 的 secret;Push verification 由后端通过 JMAP API 动态完成并只在 Redis 短期保存状态。
---

View File

@ -1,13 +1,13 @@
# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计
> 状态:**调研与方案设计**(未写代码)
> 状态:**设计与实现均已落地**(代码见仓库 `src/`、`web/`、`cloudflare-worker/`;本文同时保留设计决策与选型评估的原始记录,阶段 1/2/4 的表述属历史计划,当前进度见 §10.5)
> 作者:Cowork(team: MessageWeave)
> 日期:2026-09-21
> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审
>
> **相关文档**(职责分离,避免重复堆砌):
> - [AGENTS.md](../AGENTS.md) — 给后续 AI coding agent 的硬性安全边界、实现顺序、禁止事项、测试验收与文档引用关系
> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与 4 项待确认
> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与仍需确认项
>
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至 `deployment.md` 与 `AGENTS.md`。
@ -48,7 +48,7 @@
### 1.3 调研依据
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
- 项目目录:`/home/okabe/Repo/MessageWeave/`(greenfield,首次构建需装 Rust 工具链,见 §10.0 与 AGENTS.md)
- 项目目录:`/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 AGENTS.md)
---
@ -96,7 +96,7 @@ client_ws/ WebSocket 客户端(feature = "websockets")
| 发邮件(2 步) | ① `email_set`/`email_import` 建 draft ② `email_submission_set` 发送 | submission 关联 identityId |
| 删除/归档 | `email_set`(keywords `$seen`/`$flagged`)、`mailbox_destroy` | JMAP 无真"删除",靠 keyword/搬家 |
| 搜索 | `email_query` Filter + `SearchSnippet/get` 高亮 | |
| 实时通知(**唯一**通道) | `push_subscription` 注册 HTTPS 回调 → `StateChange` | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);+ 外部 Cron 对账兜底 |
| 实时通知(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 认证机制(`client.rs`)
@ -122,12 +122,12 @@ WebSocket(...) ws 错误(feature 开启时)
### 2.6 实时通道决策(已定,见 deployment.md)
> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。
> 据此**唯一通道 = JMAP Push HTTPS 回调**(`push_subscription` + verificationCode 握手)+ 外部 Cron 对账兜底。
> 当前通道 = JMAP Push HTTPS 回调(先由管理员通过 `POST /api/push/register` 显式注册,见 §7.3)+ 外部 Cron `/reconcile` 对账兜底。Push 订阅不会自动创建,不能把 Push 当作唯一可靠来源。
> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。
| 通道 | 是否采用 | 备注 |
|---|---|---|
| **Push Subscription(HTTP 回调)** | ✅ **唯一采用** | 短请求模型,契合无状态 + 无长连接;`jmap-client` 提供 create/verify/update/destroy |
| **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` 冲突;且需服务端支持 |
@ -262,7 +262,7 @@ teloxide = { version = "0.17", features = [
- 启动时:
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` 现阶段为占位 200**,`ARCH-READY-PLACEHOLDER`)。
3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开轻量探针(`SAF-PROBE-PUBLIC`),不代表外部依赖已完成端到端验收。
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`)。
@ -274,12 +274,12 @@ teloxide = { version = "0.17", features = [
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。
- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。
- **阶段0 现状**(`ARCH-STAGE0`):完成 ①加载配置(env)、③HTTP 入口路由,并**已实现三入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed)**;业务体仍为无副作用占位。②④(JmapService / Redis Streams worker)为阶段1/4 目标,尚未实现。`GATE-P0` 未全项通过前**不应暴露公网**。
- **当前现状**:完成配置引导、HTTP 入口和写入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed);JMAP session、Email/changes、PushSubscription create/update、Redis Streams worker 和 `/reconcile` 已接入。`/reconcile` 与 Push 闭环仍需在真实 Stalwart、Redis、Telegram 环境完成端到端验收,不能仅凭本地门禁宣称生产链路已验证。
### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL)
```
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ email_get([From, Subject, Preview, ReceivedAt, HasAttachment])
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
@ -305,7 +305,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
```
### 5.6 会话状态机(对话 FSM)
命令路由基于 teloxide `Dialogue`/对话 FSM(多步交互如 `/send`、`/summarize` 确认)。AI 与附件相关流程必须经过显式确认态:
**目标设计**:命令路由基于对话 FSM(多步交互如 `/send`、`/summarize` 确认)。当前实现**未引入 teloxide**,worker 用 `parse_intent` 解析为 `Intent`,AI 授权态走 Redis TTL 而非多步确认态;AI 与附件相关流程必须经过显式确认态:
| 状态 | 含义 | 进入 | 离开 |
|---|---|---|---|
@ -316,7 +316,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 |
不变量:`AwaitConfirm`/`Analyzing`/`AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**。会话状态为短期状态,统一走**外部 Redis 短期 TTL**(`C-REDIS-ONLY-STATE`;不使用 SQLite,丢失可接受),运维 TTL 见 deployment.md(`C-REDIS-ONLY-STATE`)。
> 渠道中立:FSM 状态与事件用领域类型(`domain::state`),不依赖 teloxide;teloxide `Dialogue` 仅作为 Telegram adapter 的会话存储实现。
> 渠道中立:FSM 状态与事件用领域类型(`domain::state`),不依赖 teloxide;当前实现未引入 teloxide,会话/授权态统一走外部 Redis 短期 TTL。
---
@ -350,7 +350,7 @@ message-weave/
│ │ # 预留扩展位:dingtalk/、feishu/(不实现,见 §5.2)
│ ├── notify/
│ │ ├── core.rs # 新邮件处理:JMAP 增量 → 领域 Notification(渠道无关)
│ │ ├── push_handler.rs # POST /push/jmap:校验 verificationCode → 去重 → 入 Redis Streams → 2xx
│ │ ├── push_handler.rs # POST /push/jmap:校验 subscription ID + verificationCode → 去重 → 入 Redis Streams → 2xx
│ │ ├── worker.rs # Redis Streams 消费:Email/changes → 通知 → 推进 sinceState → XACK
│ │ └── reconcile.rs # /reconcile:外部 Cron 触发对账补差(FLOW-RECONCILE,Redis 丢失恢复)
│ └── util/
@ -364,7 +364,7 @@ message-weave/
### 6.1 模块职责矩阵
> 阶段0 已落地:`main` / `config` / `error` / `domain` / `channel` / `notify`(axum 入口,见 §10.0)。阶段1 起 `jmap-client 0.4.2` **已引入**(`ARCH-DEPS-STAGE1`)。下表含**后续阶段**将引入的模块与依赖(teloxide / redis / reqwest 等**尚未加入 Cargo.toml**,标注「计划」)。
> **依赖现况**:阶段0 已落地(`main` / `config` / `error` / `domain` / `channel` / `notify`,见 §10.0);`jmap-client 0.4.2` **已引入**(`ARCH-DEPS-STAGE1`);`redis`(`0.27`) / `reqwest`(`0.13`) 亦**已引入**(`ARCH-DEPS-STAGE4`,版本以 `Cargo.toml` 为准)。`teloxide` 经评估后**未引入**——Telegram 渠道改用 `reqwest` 自研实现。下表保留选型评估的原始记录(含未采纳方案),标「计划」的行已不代表当前计划。
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|---|---|---|---|---|
@ -374,7 +374,7 @@ message-weave/
| `domain::state` | — | 意图路由 + 会话 FSM(零渠道依赖) | 表驱动纯单测 | 阶段3+ |
| `channel`(traits) | — | Channel/Notifier/MessageAdapter | 契约测试(MockChannel) | 阶段0 边界已实现 |
| `channel::telegram` | teloxide(计划) | TG adapter:事件→领域 Command;领域 Notification→渲染 | teloxide mock Bot / MockChannel | 阶段2+ |
| `notify`(入口 + Push/worker/reconcile) | axum(已实现占位);Redis Streams(计划) | 路由占位 → 增量 → 领域 Notification | 驱动假 StateChange 流 | 阶段0 路由占位;阶段4 实现 |
| `notify`(入口 + Push/worker/reconcile) | axum;Redis Streams | HTTP 鉴权、全局开关、Push 入队、`Email/changes` 对账和游标提交 | 领域 Notification → Streams worker | 当前实现已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
| `util::retry` | — | 重试策略 | 纯逻辑 | 阶段1+ |
---
@ -393,6 +393,8 @@ message-weave/
> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。
> ⚠️ **下表是 `Config::from_env()` 遗留/引导路径的完整变量表**(阶段0 口径,保留作兼容参考)。**当前生产部署只需要 `REDIS_URL` + `CONFIG_ENCRYPTION_KEY` 两个启动变量**(`src/main.rs`);其余业务字段已迁移到 Redis 业务配置(`PUT /api/business-config` 热加载,`C-REDIS-ONLY-STATE`)。表中的「必填」仅指**走环境变量引导路径时**必填,不代表当前生产必须配置。
| 变量 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) |
@ -403,18 +405,18 @@ message-weave/
| `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`) |
| `JMAP_PUSH_VERIFICATION` | 是 | — | `/push/jmap` 鉴权:请求体 `verificationCode`(`SAF-AUTH-JMAP-PUSH`;`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` | 否 | `3` | 熔断阈值(`REQ-AI-FUSE`) |
| `LLM_MAX_RETRIES` | 否(环境变量路径**不读取**) | `3` | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认 `3`,见 `src/ai.rs`);`Config::from_env()` **不读取**此变量 |
| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) |
> 阶段0 `config.rs` 已建立上述字段骨架(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`),未使用的占位以局部 `#[expect(dead_code, reason="稳定ID+阶段0占位")]` 标注。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。
> **入口鉴权三密钥(`TG_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION`/`RECONCILE_TOKEN`)均为必填**(fail-closed,`SAF-AUTH-*`):缺失即 `Config::from_env()` 报错启动失败,无"未配置则放行"降级。示例占位见仓库根目录 `.env.example`(仅占位符,禁放真实密钥)。
> 当前 `config.rs` 通过环境变量和 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`)。未来若引入 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。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。)
### 7.2 密钥管理
@ -428,14 +430,14 @@ message-weave/
- **入口鉴权(硬约束 `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`:请求体 `verificationCode == JMAP_PUSH_VERIFICATION`。
比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。三密钥必填,缺失即启动失败(§7.1)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready`(`ARCH-READY-PLACEHOLDER`)为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/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` **当前阶段为占位**:固定返回 `200`,**不执行** Redis PING / JMAP session / Telegram getMe 等依赖探测(`ARCH-READY-PLACEHOLDER`)。真实依赖探测属后续门禁 `GATE-READY-DEPS`(`redis` 接入且 JMAP session 可用后实现,见 §10.0)。
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;**强制拒绝逻辑随阶段2 渠道接入落地**。
- `/ready` 检查配置完整性与 Redis 可访问性;未就绪返回 `503`,不执行 JMAP/Telegram 请求,也不触发邮件同步等业务副作用。
- **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/状态操作之前先校验白名单,拒绝即终止)。
- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。
- **速率**:teloxide `throttle` + `notify` 自有令牌桶(推送不能挤占用户命令回复)。
- **速率**:出站侧未建本地令牌桶(未引入 `teloxide`);Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 不做专门的自动退避处理。
### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
@ -452,7 +454,7 @@ message-weave/
}
```
- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。
- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;成功返回 `204 No Content`,不会返回配置或密钥。Wire 字段为 `bot_token: string`、`telegram_chat_id: i64`、`chat_allowlist: i64[]`、`telegram_webhook_secret: string`、`jmap_session_url: string`、`jmap_username: string`、`jmap_password: string`、`jmap_push_verification: string`、`account_id: string | null`、`llm_enabled: boolean`、`llm_allow_net: boolean`、`llm_api_key: string | null`、`llm_base_url: string | null`、`llm_model: string | null`、`reconcile_token: string`、`worker_token: string`。该 API 故意没有 GET;SPA 不回填业务配置,每次完整替换需重输必填密钥。
- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。成功返回 `204 No Content`,不会返回配置或密钥。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
### 7.4 TLS(代码/依赖行为)
@ -479,6 +481,8 @@ message-weave/
## 8. 错误处理与可观测性
### 8.1 统一错误枚举
> 以下为设计目标形态。当前 `src/error.rs` 的 `BotError` 仅含 `Config` / `Io` / `Json` / `State` 四个变体;`Telegram` 变体未引入(`teloxide` 未使用)。
```rust
#[derive(thiserror::Error, Debug)]
pub enum BotError {
@ -510,8 +514,8 @@ pub enum BotError {
| `Llm(*)` | 见 §12.7(熔断/回退/徽标) |
### 8.3 可观测性
- `tracing`(teloxide feature 已开启)+ `tracing-subscriber`(fmt + EnvFilter)。
- 关键 span:`jmap.request`、`notify.push_received`、`notify.reconcile`、`telegram.command`、`llm.call`(记 prompt 哈希/response 长度/latency/status,**不记 prompt 明文**)。
- `tracing`(直接依赖,非 `teloxide` feature)+ `tracing-subscriber`(fmt + EnvFilter)。
- 关键事件:Push 回调到达/验证/拒绝、Reconcile 拉取与入队、渠道推送成功/失败/重试、LLM 调用耗时与状态等均落 `tracing` 事件(stdout);**不记 prompt 明文与密钥**。当前未使用命名 span(无 `#[instrument]` / `span!`),字段级结构化观测待后续补齐。
- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。
- 优雅退出:信号处理 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(不涉及本地文件)。
- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。
@ -547,7 +551,7 @@ pub enum BotError {
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 返回 `200` 且**响应体不含敏感信息**;`/ready` 在阶段0 断言为**占位**(不做依赖探测,测试**不得**断言 Redis/JMAP/TG 检查已发生)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200` 表示进程存活;`/ready` 以 `200/503` 表示配置与 Redis 是否就绪,响应体不含敏感信息。
- **渠道解耦**:领域模块(jmap/llm/intent/notify::core)编译不依赖 teloxide(crate 分层 + clippy/评审约束);`channel::telegram` 是唯一导入 teloxide 的模块。
- **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 被拒绝。
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
@ -560,18 +564,18 @@ pub enum BotError {
### 10.0 阶段 0:脚手架与 HTTPS 入口骨架(已完成,待过 P0 门禁)
> **阶段0 实际交付**(`ARCH-STAGE0`):`cargo` 工程 + 模块骨架(`main` / `config` / `error` / `domain` / `channel` / `notify`),单端口 axum 入口与 5 条路由占位(`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`),env 配置骨架、最小测试。其中三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);**`/healthz` 为 liveness;`/ready` 为占位 200(`ARCH-READY-PLACEHOLDER`),不做依赖探测**(真实探测见后续门禁 `GATE-READY-DEPS`)。
> **当前实际边界**:单端口 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/JMAP 依赖已完成业务验收。
- **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):`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"]`);dev-dependencies:`tower 0.5`(路由测试)。
- **尚未引入**(计划见后续阶段,文档不得声称已用):`teloxide`(阶段2)、`redis`(阶段3/4)、`reqwest`(阶段3.5)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。
- **实际依赖**(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`(阶段2 渠道曾计划使用,最终未引入;Telegram 出站由 `src/channel.rs` 用 `reqwest` 直发)。**未使用 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 为准并同步本节。
- 阶段0 **不实现** SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);**入口鉴权(`R1`/`SAF-AUTH-*`)已作为 fail-closed 硬门禁落地**(见 §7.3、§10.1 注),鉴权之后的业务体仍为占位。
- 当前不实现 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`)——进入阶段1前必须全部通过:**
1. `cargo fmt --check` 通过(无格式差异)。
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;占位符用**局部** `#[expect(dead_code, reason="稳定ID+阶段0占位")]`,禁 crate 级 `allow`)。
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` 安全访问器保留。
@ -579,11 +583,10 @@ pub enum BotError {
7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
**后续门禁(`GATE-READY-DEPS`)——`redis` 接入且 JMAP 只读 adapter 可用后必须实现:**
- `/ready` 由**占位 200**(`ARCH-READY-PLACEHOLDER`)升级为**真实依赖探测**:Redis PING + JMAP session(Basic)连通性(+ 视需要 Telegram getMe);
- 依赖不可用时返回 `503`,用于编排器的 readiness/流量摘除;
- `/healthz` 保持纯 liveness;两者继续满足 `SAF-PROBE-PUBLIC`(公开、无敏感信息)。
- **在 `GATE-READY-DEPS` 落地前,文档/测试/编排探针描述不得声称 `/ready` 已检查依赖。**
**运行监控(`GATE-UPTIME-KUMA`):**
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置/Redis 就绪),分别期望 HTTP 200。
- `/healthz` 保持纯 liveness;`/ready` 不执行 JMAP/Telegram 请求或业务副作用。
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
**生产红线(贯穿所有阶段)**:
- **无数据库**(`C-NO-DB`):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。
@ -594,8 +597,8 @@ pub enum BotError {
**阶段1 推进边界(`BOUND-STAGE1`):**
- 仅在 `GATE-P0` 全项通过后进入阶段1(JMAP 只读)。
- **R1(入口真实鉴权)已在阶段0 后置修复中落地**(`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` 校验 `verificationCode == JMAP_PUSH_VERIFICATION`;常数时间比较,失败 `401` 且无副作用,三密钥必填(缺失即启动失败)。阶段1 不再重复实现 R1。
- **仍需注意**:`GATE-P0` 未全项通过前**不应**将容器暴露到公网(鉴权已 fail-closed,但依赖/占位与 CI 门禁尚未验收)。
- **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 阶段 1:JMAP 只读(1.5d)
@ -605,7 +608,7 @@ pub enum BotError {
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 env 骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `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`)、**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` 三入口鉴权(`SAF-AUTH-*`)已在阶段0 落地并通过单测;阶段1 仅需在其后接入真实业务逻辑(不要在阶段1 放宽/绕过鉴权)。
- **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::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
@ -626,18 +629,55 @@ pub enum BotError {
### 10.4 阶段 3.5:LLM 门面 + 回退(1.5d)
见 §12.9。
### 10.5 阶段 4:实时推送(Push 回调 + Streams worker + 外部 Cron 对账)(2d)
- `notify::push_handler`:注册 `push_subscription` + verificationCode 握手,`POST /push/jmap` 校验→去重→入 Redis Streams→2xx。
### 10.5 已实现能力:实时推送(Push 回调 + Streams worker + 外部 Cron 对账)
- `notify::push_handler`:`POST /api/push/register` 接受显式 HTTPS callback URL 并创建订阅;接收 Stalwart 的 `PushVerification`,通过 JMAP `PushSubscription/set` 回写验证码,随后 `POST /push/jmap` 校验→去重→入 Redis Streams→2xx。
- `notify::worker`:Redis Streams 消费(`mod_streams`)→ `Email/changes` 增量 → 通知 → 推进 sinceState → XACK。
- `notify::reconcile`:`/reconcile` + `reconcile` 子命令,外部 HTTPS Cron 触发(`FLOW-RECONCILE`);兼做 Redis 丢失后的游标重建。
- `sinceState` 写外部 Redis(`mod_sincestate`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。
- 验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。
- 目标验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。当前代码门禁已通过,但仍需在真实 Stalwart、Redis 和 Telegram 环境完成端到端验证,不能将此处目标当作已验证事实。
### 10.6 阶段 5:搜索 + 搜索片段 + 打磨(1.5d)
- `/search` + `SearchSnippet/get` 高亮。
- 错误消息打磨、速率/退避实测、`clippy`/`fmt`、README。
### 10.5.1 Redis Streams 事件保障边界(FLOW 运行语义)
### 10.7 总估时
Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义如下:
**键与 TTL**
| 键 | 作用 | TTL | 说明 |
|---|---|---|---|
| `delivery:inflight:{stream}:{id}` | 处理中租约 | 60s | 崩溃后可被 XAUTOCLAIM 回收重试 |
| `delivery:committed:{stream}:{id}` | 幂等/重放标记 | 7d | 已成功投递的哨兵,防止重放重复打扰 |
| `dedup:jmap:{account}:{email}` | 对账去重 | 24h | 入队时原子抢占,同封邮件 24h 内只通知一次(有意设计,见 11.5) |
| `config:enabled` 等配置 | — | — | 缺键视为关闭(fail-closed),不进业务 |
| `push:subscription:{id}` | Push 订阅的验证码摘要 | 7d | 只存摘要不存明文;`push:disable` 一并清理 |
| `push:orphan:{subscription_id}` | 注册失败后的 JMAP destroy 补偿记录 | 7d | 订阅已创建但回写/销毁失败时记录,供后续清理 |
| `push:registration:{...}` | 注册单飞锁 | 360s | 幂等创建,owner-token 防并发重复注册 |
**投递流程**(`notify::worker`,一次 XREADGROUP 批量 ≤10 条)
1. 读批 → 逐条 `process` → XACK。
2. 处理中写入 `delivery:inflight`(60s)作租约;成功后写 `delivery:committed`(7d)。
3. 崩溃于 `inflight` 租约窗口内的条目,由另一实例经 XAUTOCLAIM(idle 阈值 300s)回收重试 —— 至多重复、不丢。
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/todo.md`)**
- Redis 错误映射:`read_batch` 与 `retry_or_dlq` 在 Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败而退出;这类故障只能靠 Redis 侧告警发现。
- 多实例重复投递窗口:XAUTOCLAIM 空闲阈值已由固定 300s 改为「批大小 × 单条上限 300s」;单实例不受影响,仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领,导致**重复投递(仅重复,不丢)**。
**审计意见 → 收口(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` 语义本身仍需真实 Stalwart 复验(见 `docs/todo.md`)。
- 其余低风险项均已收口:未知 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「搜索 + 搜索片段 + 打磨」)见 `docs/todo.md`「未排期」。
### 10.6 总估时
~10.5 人日(不含等待用户确认与真实联调排障)。
---
@ -651,9 +691,10 @@ pub enum BotError {
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
### 11.2 实时通道(已定,见 deployment.md)
- 唯一通道 = **JMAP Push HTTPS 回调**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。
- **Q2**:Stalwart 实例是否启用 JMAP `PushSubscription` capability(`urn:ietf:params:jmap:push`)?需确认,决定 Push 回调可用性。
- **Q3**:Stalwart 对 Push 回调的重试策略/次数?决定去重 TTL 与对账间隔取值(见 deployment.md)。
- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron `/reconcile` 对账**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发,外部 Cron 仍是必须的可靠补偿通道。
- **Q2**:部署前确认 Stalwart JMAP capability 和账号权限;官方 user/admin 角色通常已包含 PushSubscription 的 get/create/update/destroy 权限。若 `POST /api/push/register` 返回 `forbidden`,按 Stalwart 版本和角色配置排查。
- **Q3**:Stalwart 对 Push 回调的重试策略/次数?当前实现对 callback 注册和对账采用 owner-token 单飞锁,
Push 验证码只保存摘要;仍需根据真实部署的重试观测校准 TTL 与对账间隔(见 deployment.md)。
### 11.3 部署形态(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。
@ -675,7 +716,7 @@ pub enum BotError {
### 11.7 工程取向
- **Q13**:是否要求 `#![forbid(unsafe_code)]`(与 jmap-client 一致)?建议是。
- **Q15**:日志/指标是否需要对接外部(Loki/Prometheus)?默认只本地 stdout。
- **Q15**:运行监控采用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz` 与 `/ready`;不引入 Prometheus、Exporter 或额外指标端口。详细配置见 deployment.md。
- (CI 平台为通用容器 CI,`NG-SERVERLESS-BIND`;具体见 deployment.md。)
---
@ -737,7 +778,7 @@ export LLM_SUMMARY_TARGET_CHARS=300
- AI 摘要**永远不**把附件内容喂入 prompt(除非后续单独功能开启且白名单限定),保持隐私默认。
### 12.3.1 AI 授权期限与 Redis TTL(`REQ-AI-CONSENT`)
- AI 授权必须由用户明确选择期限;可选临时一次(实现为一次授权窗口,默认 1 小时)、1 小时、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。
- 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。
@ -805,5 +846,5 @@ LlmService.call(kind: LlmKind, input, ctx)
- `stalwartlabs/jmap-client` main 分支:`src/lib.rs`(URI/Method/DataType/Error)、`src/client.rs`(认证/连接/event_source)、`src/email/`、`src/email_submission/helpers.rs`、`src/event_source/`、`src/push_subscription/`(create/verify/update_types/destroy)、`src/core/error.rs`、`Cargo.toml`、`README.md`、`examples/`。
- crates.io:`teloxide` 0.17(features 列表)、`jmap-client` 元数据。
- `stalwartlabs/mail-server` main 分支:`crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`crates/jmap/src/push/`、`api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
- 项目目录 `/home/okabe/Repo/MessageWeave/`:greenfield(首次构建需 `rustup`)。
- 项目目录 `/home/okabe/Repo/messageweave/`(工具链要求见 §10.0 与 AGENTS.md)。
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。

34
docs/todo.md Normal file
View File

@ -0,0 +1,34 @@
# 未完成项与阻塞原因
> 本文件取代已删除的根目录 `HANDOFF.md`。只保留未完成项与阻塞原因;已完成能力见 `README.md`、`docs/design.md`、`docs/deployment.md`。门禁命令见 `docs/deployment.md` §8.0。
## 阻塞(需要真实环境,当前无法验证)
- **真实 Stalwart 邮箱联调**:未运行任何真实邮箱端到端集成。JMAP 客户端代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 等路径没有用真实邮箱账号验证过。→ 阻塞原因:无可用 Stalwart 账号/凭据,禁止使用测试账号或伪造结果。
- **真实 Telegram Bot 联调**:`/webhook/tg` 的签名校验、`getUpdates` 迁移、`setWebhook` 注册、`sendMessage` 出站均未在真实 Bot 上验证。→ 阻塞原因:无 Bot Token,禁止向真实用户发消息。
- **真实 Redis TLS 连接**:`REDIS_URL` 使用 `rediss://` 的 TLS 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新均未实测。→ 阻塞原因:无 Upstash/托管 Redis 实例。
- **`Email/changes` 的 `newState` 语义**(design.md §10.5.1 应修-1):客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。→ 阻塞原因:无可用 Stalwart 账号/凭据。
- **Push callback 公网映射**:`push:registration` 注册锁(360s)与 callback 映射(7d)TTL 依赖真实 Stalwart 的回调时序。→ 阻塞原因:无真实推送回调可观察。
## 代码缺口(已定位,待实现)
- **Redis 错误映射**:`read_batch` 与 `retry_or_dlq` 在 Redis 出错时仍可能返回 `Ok(())`,消费循环不会因单次失败退出,这类故障只能靠 Redis 侧告警发现(可选改进)。
- **多实例重复投递窗口(残余)**:XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢),单实例不受影响。
- **Worker safelist 与注册流程不一致**:`POST /api/push/register` 不在 `cloudflare-worker/src/backends.js` 的 `SAFE_ROUTES` 中,Worker 对其返回 404;`docs/deployment.md` §10.4 已修正为直连后端实例。→ 待决策:加入 safelist,或保持直连后端。
## 未排期(阶段 5「搜索 + 搜索片段 + 打磨」)
- `/search` + `SearchSnippet/get` 高亮。
- 错误消息打磨、速率/退避实测、`clippy`/`fmt`、README 复核。
## 监控验收(Q-DEP-B)
- 容器不写日志文件、只走 stdout,已由 `docker`/`docker-compose.yml` 检查项覆盖。
- 业务事件、计数、时间戳、脱敏摘要的结构化日志已由代码审查确认。
- 未验证项:真实平台的日志采集器是否落盘、是否可检索。**→ 阻塞原因:Q-DEP-B 明确允许延后到真实部署验收。**
## 本轮已收口(2026-09-26)
- **错误 envelope 统一**:全部公开写路由错误响应改为 `{"error":<code>,"request_id":<id>}`,可重试的 503 追加 `Retry-After: 30`;`GET /ready` 保留结构化诊断 JSON(Uptime Kuma 运行契约),不改 envelope。
- **Web SPA i18n**:按浏览器语言判断 `zh`/`en`,默认英文,保持纯静态 SPA(`data-i18n` 属性 + 内联消息字典,无新增资源文件)。
- **文档**:`README.md` 改英文默认,新增 `README.zh-CN.md`,两版章节与关键事实 1:1 对齐;`docs/design.md`、`docs/deployment.md`、`AGENTS.md` 同步修正滞后描述。