MessageWeave/docs/design.md

809 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计
> 状态:**调研与方案设计**(未写代码)
> 作者:Cowork(team: stalwart-bot)
> 日期: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 项待确认
>
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至 `deployment.md` 与 `AGENTS.md`。
---
## 0. 文档导航
1. [任务与范围](#1-任务与范围)
2. [jmap-client 能力分析](#2-jmap-client-能力分析)
3. [认证与邮箱操作适配](#3-认证与邮箱操作适配)
4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型)
5. [整体架构与数据流](#5-整体架构与数据流)
6. [模块划分](#6-模块划分)
7. [配置与安全](#7-配置与安全)
8. [错误处理与可观测性](#8-错误处理与可观测性)
9. [测试策略](#9-测试策略)
10. [分阶段实施计划](#10-分阶段实施计划)
11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类)
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退)
> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并收敛;剩余待办仅 `Q-DEP-A`/`Q-DEP-B`,见 deployment.md §9。
---
## 1. 任务与范围
### 1.1 目标
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**:
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。)
- 单用户或多账户部署(默认面向单账户自托管场景)。
### 1.2 范围(本阶段)
- ✅ 调研 + 方案设计(本文档)
- ❌ 不创建/修改项目代码、不初始化 cargo 工程
- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项
### 1.3 调研依据
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
- 项目目录:`/home/okabe/Repo/stalwart-bot/`(greenfield,首次构建需装 Rust 工具链,见 §10.0 与 AGENTS.md)
---
## 2. jmap-client 能力分析
来源:直接阅读 `stalwartlabs/jmap-client` 仓库 `src/lib.rs`、`src/client.rs`、`src/email/`、`src/email_submission/`、`src/event_source/`、`Cargo.toml`、`README.md`、`examples/`。
### 2.1 crate 概况
| 项 | 值 |
|---|---|
| crate 名 | `jmap-client`(crates.io) |
| 协议覆盖 | JMAP Core (RFC 8620)、Mail (RFC 8621)、WebSocket (RFC 8887)、Sieve (draft-12) |
| 异步运行时 | tokio + reqwest |
| 许可证 | Apache-2.0 OR MIT |
| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ |
| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 |
### 2.2 模块结构(`src/`)
```
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
client.rs Client:认证、connect、session、send_request、event_source、ws
core/ request / response / get / set / query / query_changes / changes / error / session
email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…)
email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询)
mailbox/ helpers(mailbox_create/query/get/set/destroy…)
thread/ thread/get
identity/ Identity(发件身份)get/set
blob/ blob 上传/复制(附件)
sieve/ SieveScript(服务端过滤脚本)
vacation_response/
principal/ Principal(账户/共享)
push_subscription/ PushSubscription(HTTP push 回调注册)
event_source/ SSE 流:mod / parser / stream
client_ws/ WebSocket 客户端(feature = "websockets")
```
### 2.3 关键能力映射到本 Bot
| Bot 需求 | jmap-client API | 备注 |
|---|---|---|
| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities |
| 列文件夹 | `mailbox_query` / `mailbox_get` | 带 `role` 可识别 INBOX/重要/草稿等 |
| 列邮件 | `email_query`(Filter + Comparator + anchor 分页) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… |
| 读邮件正文 | `email_get` + `Property` 选择 | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` |
| 读附件 | `Blob/get`(blobId)或 `email_parse` | 大附件需分片/流式下载 |
| 发邮件(2 步) | ① `email_set`/`email_import` 建 draft ② `email_submission_set` 发送 | submission 关联 identityId |
| 删除/归档 | `email_set`(keywords `$seen`/`$flagged`)、`mailbox_destroy` | JMAP 无真"删除",靠 keyword/搬家 |
| 搜索 | `email_query` Filter + `SearchSnippet/get` 高亮 | |
| 实时通知(**唯一**通道) | `push_subscription` 注册 HTTPS 回调 → `StateChange` | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);+ 外部 Cron 对账兜底 |
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
### 2.4 认证机制(`client.rs`)
- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。
- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。
- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。
- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。
- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。
### 2.5 错误模型(`Error` 枚举)
```
Transport(reqwest::Error) 网络/TLS/超时
Parse(serde_json::Error) 序列化
Internal(String) 客户端内部
Problem(Box<ProblemDetails>) JMAP problem-details
Server(String)
Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…)
Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败)
WebSocket(...) ws 错误(feature 开启时)
```
- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。
### 2.6 实时通道决策(已定,见 deployment.md)
> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。
> 据此**唯一通道 = JMAP Push HTTPS 回调**(`push_subscription` + verificationCode 握手)+ 外部 Cron 对账兜底。
> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。
| 通道 | 是否采用 | 备注 |
|---|---|---|
| **Push Subscription(HTTP 回调)** | ✅ **唯一采用** | 短请求模型,契合无状态 + 无长连接;`jmap-client` 提供 create/verify/update/destroy |
| EventSource/SSE | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突(`NG-POLLING-SSE`) |
| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突;且需服务端支持 |
对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。
---
## 3. 认证与邮箱操作适配
### 3.1 认证策略(已确认:App Password + Basic)
- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。
- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。
- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。
- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。
### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义)
建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词:
| Bot 动词 | 封装函数 | 内部 JMAP |
|---|---|---|
| `list_folders()` | → `Vec<Folder>` | `mailbox_query` + `mailbox_get`(缓存 role→id) |
| `list_emails(folder, page)` | → `Vec<EmailSummary>` | `email_query`(anchor 分页) + `email_get`([Subject, From, Preview, ReceivedAt, HasAttachment]) |
| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]);**多 part 原文**(`REQ-JMAP-RAW-MULTIPART`):按 `text_body` 顺序筛选"有 `part_id` 且 `bodyValue`"的 part 后**拼接**为 `text`;若无可用部分 → 返回**明确错误**(不静默返回空串)。附件用 `Blob/get` |
| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) |
| `set_flag(id, keyword)` | → () | `email_set` keywords |
| `search(query)` | → `Vec<EmailSummary>` | `email_query`(Filter::and[…]) + `SearchSnippet/get` |
> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。
---
## 4. Telegram Rust 框架选型
### 4.1 候选评估
| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 |
|---|---|---|---|---|---|
| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、`webhooks`+`webhooks-axum`、Redis 会话存储、`throttle`、`macros`、`tracing`、`rustls` | ★★★★★ | **首选** |
| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 |
| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 |
### 4.2 选定 teloxide 的理由
1. 与 jmap-client 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
2. 内建 Dispatcher + UpdateKind 枚举匹配命令,与 Bot 的命令路由天然契合。
3. 支持 `webhooks-axum`(生产 Webhook 形态;**不使用长轮询** `NG-LONG-POLLING`)与 Redis 会话存储(记住用户上下文:当前选中的文件夹/分页游标;不用 SQLite)。
4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。
5. `tracing` feature 与本项目观测性统一。
6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。
### 4.3 teloxide 关键 feature 选择(**计划,阶段2 引入**)
> ⚠️ 阶段0 **未引入 teloxide**(`ARCH-DEPS-STAGE0`);下方为阶段2 引入时的目标 feature 集,不得据此认为 `Cargo.toml` 已含 teloxide。
```
# 阶段2 计划(尚未加入 Cargo.toml)
teloxide = { version = "0.17", features = [
"macros", "rustls", "rustls-native-roots", # TLS 与 jmap-client 一致
"redis-session", # 无状态会话:走外部 Redis(deployment.md C-REDIS-ONLY-STATE)
"throttle", # 遵守 TG 速率限制
"tracing",
] }
# 部署形态 = Webhook(非长轮询,deployment.md NG-LONG-POLLING);按需启用 "webhooks-axum"
```
### 4.4 备选/降级方案
- 若 teloxide 升级/破坏性改动 → 可退到直接基于 `teloxide-core`(更薄、API 稳定)。
- 若需更高吞吐(多账户)→ 用 `webhooks-axum` + 共享 `axum::Router`(与阶段0 的单端口 axum 入口叠加)。
---
## 5. 整体架构与数据流
### 5.1 高层拓扑
```
┌───────────────────────────────────────────────────┐
│ Telegram │
└───────────────────────┬───────────────────────────┘
Webhook(HTTPS) │
┌───────────────────────▼───────────────────────────┐
│ HTTP 入口 (axum/teloxide-webhooks,单端口 PORT) │
│ POST /webhook/tg · POST /push/jmap · │
│ POST /reconcile · GET /healthz /ready │
│ GET / + admin session + config APIs (管理 SPA) │
└───────────────────────┬───────────────────────────┘
命令 │ 通知 ↓(Redis Streams worker)
┌───────────────────────▼───────────────────────────┐
│ bot 层 (handlers) │
│ /list /read /send /search /folders /flag ... │
└───────────────────────┬───────────────────────────┘
领域 │
┌───────────────────────▼───────────────────────────┐
│ jmap 适配层 (JmapService) │
│ list_folders/list_emails/read_email/send_email… │
│ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │
└───────────────────────┬───────────────────────────┘
JMAP │ HTTPS(短请求)
┌───────────────────────▼───────────────────────────┐
│ Stalwart JMAP Server │
│ session_url / email/* / mailbox/* / submission/* │
└───────────────────────▲───────────────────────────┘
Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE)
┌───────────────────────┴───────────────────────────┐
│ push handler → Redis Streams(MOD-STREAMS) │
│ worker: Email/changes → 通知 → 发往 TG → 推进游标│
└───────────────────────────────────────────────────┘
```
> 部署形态 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`):
> Telegram 仅 Webhook;JMAP 实时仅 Push 回调;慢任务异步入 Redis Streams;对账由**外部 HTTPS Cron** 触发。
> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
> 渠道解耦(见 §5.2):领域层(`domain/`)与渠道层(`channel/`)以领域 Command / Notification 交互,不出现 Telegram/teloxide 类型;钉钉/飞书仅保留扩展位,不提前实现。
### 5.2 渠道抽象与多通道扩展策略
- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。
- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。
- **抽象(不过度设计)**:
| 抽象 | 职责 | 首个实现 |
|---|---|---|
| `Channel` | 渠道生命周期:接收输入、分发命令、配置端点 | `TelegramChannel`(teloxide Dispatcher) |
| `Notifier` | 主动推送:把领域 `Notification` 发送到用户 | `TelegramNotifier`(teloxide requester) |
| `MessageAdapter` | 领域数据 ↔ 渠道消息渲染(文本/按钮/转义) | `TelegramMessageAdapter`(`render.rs`) |
- 领域与渠道之间用**领域 Command / 领域 Notification** 数据结构传递,渠道只在边缘做适配(解析→领域 Command;领域 Notification→渲染)。
- **约束**:
- `jmap` / `llm` / 意图状态机 / `notify::core` 的公开接口只接受/返回领域类型;
- `teloxide` 类型只允许出现在 `channel/telegram/` 内;
- 新增渠道 = 新 adapter + 装配,不改领域层。
- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。
### 5.3 运行模型(短请求,无长连接)
- 单一二进制 `stalwart-bot`,`#[tokio::main]`。
- 启动时:
1. 加载配置(`config::Config`)。
2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。
3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`;**`/ready` 现阶段为占位 200**,`ARCH-READY-PLACEHOLDER`)。
4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
- 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。
- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。**
- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**:
- **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。
- **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。
- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。
- **阶段0 现状**(`ARCH-STAGE0`):完成 ①加载配置(env)、③HTTP 入口路由,并**已实现三入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed)**;业务体仍为无副作用占位。②④(JmapService / Redis Streams worker)为阶段1/4 目标,尚未实现。`GATE-P0` 未全项通过前**不应暴露公网**。
### 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
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ email_get([From, Subject, Preview, ReceivedAt, HasAttachment])
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
→ channel::Notifier.send(chat, notification)(Telegram adapter 渲染并发送)
→ 更新 sinceState(写外部 Redis)→ XACK
→ 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发
```
> 关键:`Email/changes` 在 `created` 列出新建邮件 id,避免全量 `email_query`;`sinceState` 存外部 Redis(`C-REDIS-ONLY-STATE`),Redis 丢失时由对账从 JMAP 重建,**事实源在 JMAP**。
> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 AGENTS.md 安全边界 `SAF-NOTIFY-META`)。
> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。
### 5.5 数据流:命令 `/read <seq>`(长邮件分支)
```
TG /read 3 → handler 取会话里的 folder+page 游标
→ JmapService.list_emails(folder, page=3)
→ 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM
→ 格式化:
正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape)
正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项:
"[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]"
→ reply
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
```
### 5.6 会话状态机(对话 FSM)
命令路由基于 teloxide `Dialogue`/对话 FSM(多步交互如 `/send`、`/summarize` 确认)。AI 与附件相关流程必须经过显式确认态:
| 状态 | 含义 | 进入 | 离开 |
|---|---|---|---|
| `Idle` | 空闲 | 任意完成态 | 收到消息 |
| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 |
| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 |
| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 |
| `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 的会话存储实现。
---
## 6. 模块划分
目标 cargo 工程结构(**阶段0 已落地** `main.rs`/`config.rs`/`error.rs`/`domain.rs`/`channel.rs`/`notify.rs`;下方 `jmap/`、`llm/`、`channel/telegram/`、`notify/*` 为**后续阶段目标**,详见 §10):
```
stalwart-bot/
├── Cargo.toml
├── .gitignore # 排除 target/ 等(阶段0 P0 门禁 GATE-P0)
├── src/
│ ├── main.rs # tokio main:装配置;按 RUN_MODE 分派 webhook / reconcile
│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML)
│ ├── error.rs # BotError 统一错误(见 §8)
│ ├── domain/ # 领域层(不依赖任何渠道类型,见 §5.2)
│ │ ├── command.rs # 领域 Command(List/Read/Analyze/Confirm…,渠道无关)
│ │ ├── notification.rs # 领域 Notification(NewEmail/ResultMsg/ConfirmRequest…)
│ │ ├── state.rs # 会话状态机(§5.6:Idle/AwaitClarify/AwaitConfirm/Analyzing/AwaitFallback)
│ │ ├── jmap/ # JmapService(领域封装,§3.2)—— 阶段1+
│ │ │ ├── client.rs # 包装 jmap_client::Client(构造、重连、自定义 reqwest)
│ │ │ ├── model.rs # Folder / EmailSummary / EmailBody 领域模型
│ │ │ └── state.rs # mailbox role→id 缓存、sinceState 游标(写外部 Redis,MOD-SINCESTATE;不用 SQLite/本地卷)
│ │ └── llm/ # LlmService + policy + fallback + audit(§12.1)—— 阶段3.5
│ ├── channel/ # 渠道层(每渠道一个 submodule,见 §5.2)
│ │ ├── mod.rs # Channel / Notifier / MessageAdapter traits(thin)—— 阶段0 边界已落地
│ │ └── telegram/ # 首个也是当前唯一渠道(adapter #1)—— 阶段2+
│ │ ├── mod.rs # TelegramChannel / TelegramNotifier 装配(teloxide Dispatcher)
│ │ ├── commands.rs # 领域 Command ↔ teloxide 命令映射(/folders /list /read /send /search /flag)
│ │ ├── render.rs # TelegramMessageAdapter:领域 Notification → TG 消息/行内按钮(HTML escape!)
│ │ └── session.rs # teloxide 会话键(当前 folder/page/last_email_id)
│ │ # 预留扩展位:dingtalk/、feishu/(不实现,见 §5.2)
│ ├── notify/
│ │ ├── core.rs # 新邮件处理:JMAP 增量 → 领域 Notification(渠道无关)
│ │ ├── push_handler.rs # POST /push/jmap:校验 verificationCode → 去重 → 入 Redis Streams → 2xx
│ │ ├── worker.rs # Redis Streams 消费:Email/changes → 通知 → 推进 sinceState → XACK
│ │ └── reconcile.rs # /reconcile:外部 Cron 触发对账补差(FLOW-RECONCILE,Redis 丢失恢复)
│ └── util/
│ ├── retry.rs # 指数退避(Transport/RateLimit/ServerUnavailable)
│ └── shutdown.rs # CancellationToken 协调
└── tests/
├── jmap_mock.rs # 对 JmapService 的契约测试(mock Push 回调 + changes 响应)
├── channel_mock.rs # MockChannel/Notifier 契约:领域流程不依赖 Telegram
└── telegram_dispatch.rs # teloxide 测试模式(mock Bot,仅 channel::telegram)
```
### 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**,标注「计划」)。
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|---|---|---|---|---|
| `config` | 标准库 `env`(`ARCH-CONFIG-ENV`) | `Config` 结构 | 纯函数,易测 | 阶段0 已实现(手工 `from_env`,无 figment) |
| `domain::jmap`(含 `client`) | **jmap-client 0.4.2(已引入)** | 领域动词(§3.2);`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 阶段1(G1/D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**) |
| `domain::llm` | reqwest/axum(计划) | 摘要/意图/草稿 | mock OpenAI 端点 | 阶段3.5 |
| `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 实现 |
| `util::retry` | — | 重试策略 | 纯逻辑 | 阶段1+ |
---
## 7. 配置与安全
### 7.1 历史配置读取(已迁移至 Redis)
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得
> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入
> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务
> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子
> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,
> 阶段0列表仅作为历史兼容说明。
> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。
| 变量 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) |
| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除) |
| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) |
| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) |
| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) |
| `JMAP_SESSION_URL` | 是 | — | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):可填**服务基地址** `https://host[:port]` 或**完整** `…/.well-known/jmap`;代码归一化为 origin/base 后再交 `jmap-client`(其自动追加 `.well-known/jmap`),**不得重复路径**。仅 HTTPS、**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
| `JMAP_USERNAME` | 是 | — | Stalwart 账户(邮箱) |
| `JMAP_PASSWORD` | 是 | — | **App Password**(`C-AUTH-APP-BASIC`;`SecretString`) |
| `JMAP_PUSH_VERIFICATION` | 是 | — | `/push/jmap` 鉴权:请求体 `verificationCode`(`SAF-AUTH-JMAP-PUSH`;`SecretString`) |
| `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_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`(仅占位符,禁放真实密钥)。
> **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 密钥管理
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。
- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。
- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。
- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。
- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 7.3 访问控制
- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**——
- `/reconcile`:请求头 `Authorization: Bearer <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`)为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/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 渠道接入落地**。
- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。
- **速率**:teloxide `throttle` + `notify` 自有令牌桶(推送不能挤占用户命令回复)。
### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
```json
{
"jmap_timeout_ms": 15000,
"telegram_timeout_ms": 10000,
"llm_timeout_ms": 30000,
"max_retries": 3
}
```
- 三个 timeout 单位均为毫秒,范围 `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/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(代码/依赖行为)
- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。
- 证书校验**默认强制**,仅测试可关。
- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。
### 7.5 生产红线:无数据库 / 无本地写入 / 标准输出日志(deployment.md §0/§8.2)
本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态:
| 红线 ID | 含义 | 违反示例(禁止) |
|---|---|---|
| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 |
| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写 `./logs/app.log`、写 `./tmp/…`、写本地 `sinceState.json` |
| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` |
| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 |
| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
---
## 8. 错误处理与可观测性
### 8.1 统一错误枚举
```rust
#[derive(thiserror::Error, Debug)]
pub enum BotError {
#[error("jmap: {0}")]
Jmap(#[from] jmap_client::Error),
#[error("telegram: {0}")]
Telegram(#[from] teloxide::errors::RequestError),
#[error("config: {0}")]
Config(String),
#[error("storage: {0}")]
Storage(#[from] redis::RedisError),
#[error("rate limited, retry later")]
RateLimited,
#[error("unauthorized chat {0}")]
Unauthorized(i64),
#[error("llm: {0}")]
Llm(LlmErr), // 见 §12.7
}
```
### 8.2 错误 → 用户消息 映射
| 底层错误 | Bot 行为 |
|---|---|
| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" |
| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 |
| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" |
| `Set(creation/update/destroy)` | 回复具体记录级失败原因 |
| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) |
| `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 明文**)。
- 指标(可选 `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`)。
---
## 9. 测试策略
### 9.1 层次
| 层 | 工具 | 内容 |
|---|---|---|
| 单元 | `cargo test` | `config` 解析、`util::retry` 退避、`formatting` HTML escape |
| 契约 | mock | `jmap::JmapService` 用 mock Push 回调 + changes 响应 + 预置 JMAP JSON 验证领域动词;`llm` 用 mock OpenAI 端点 |
| 集成 | teloxide mock Bot + mock JMAP | 命令端到端:发 `/list` → 验证回复来自 mock 数据 |
| 真实(手动) | Stalwart 实例 | 标 `#[ignore]`,`cargo test -- --ignored` 按需跑 |
### 9.2 mock 策略
- `JmapService` 持有 trait `JmapBackend`(`list_emails/read_email/send_email/changes…`),生产实现包装 `jmap_client::Client`,测试用 `MockBackend`(基于 `Vec<EmailSummary>`)。
- Push 回调 payload 用 `futures::stream::iter` 造假 `StateChange`,验证 worker 的 `Email/changes` 增量逻辑与去重(`mod_dedup` + `mod_streams`)。
- teloxide 提供 `Requester` mock,验证 `send_message` 被以期望内容调用(仅 `channel::telegram` 测试使用)。
- 渠道契约:领域流程用 `MockChannel`(实现 `Channel/Notifier` trait)驱动,验证领域输出与渠道渲染解耦、领域层无 teloxide 依赖。
- `llm::client` 注入 mock OpenAI `/chat/completions` 端点。
### 9.3 关键不变量测试
- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。
- 通知去重:同一 `email_id` 不重复推送。
- 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。
- **安全边界断言(硬性,见 AGENTS.md)**:
- VIEW/查看原文路径:mock AI 端点零请求(LLM client 未被调用);
- 新邮件通知:消息内无正文内容(断言泄漏);
- 长邮件:正文 > 4000 字符不发送全文;
- AI 调用前置:未确认前 LLM 零调用;
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 返回 `200` 且**响应体不含敏感信息**;`/ready` 在阶段0 断言为**占位**(不做依赖探测,测试**不得**断言 Redis/JMAP/TG 检查已发生)。
- **渠道解耦**:领域模块(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`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
---
## 10. 分阶段实施计划
> 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。
### 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`)。
- **实际依赖**(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)。
- **阶段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 注),鉴权之后的业务体仍为占位。
**阶段0 P0 门禁(`GATE-P0`)——进入阶段1前必须全部通过:**
1. `cargo fmt --check` 通过(无格式差异)。
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;占位符用**局部** `#[expect(dead_code, reason="稳定ID+阶段0占位")]`,禁 crate 级 `allow`)。
3. `cargo test` 通过(含路由/配置最小测试)。
4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。
5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。
6. `RUN_MODE` 被实际消费(`webhook`/`reconcile` 至少显式分派,保持无副作用)。
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` 已检查依赖。**
**生产红线(贯穿所有阶段)**:
- **无数据库**(`C-NO-DB`):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。
- **无本地文件写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):平台负责采集;禁用文件日志后端。
- **日志/Redis 内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥、JMAP 邮件正文、AI 请求/响应、附件内容。
- **无进程内恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅性能优化,丢失安全可重入。
**阶段1 推进边界(`BOUND-STAGE1`):**
- 仅在 `GATE-P0` 全项通过后进入阶段1(JMAP 只读)。
- **R1(入口真实鉴权)已在阶段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 门禁尚未验收)。
- 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。
### 10.1 阶段 1:JMAP 只读(1.5d)
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
- 依赖 `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 放宽/绕过鉴权)。
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**
- 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
### 10.2 阶段 2:渠道适配骨架(2d)
- `channel/` 定义 Channel / Notifier / MessageAdapter 抽象 + 领域 Command/Notification 类型。
- `channel::telegram` 装配 Dispatcher + chat 白名单中间件 + throttle(teloxide 仅存在于该模块)。
- 命令:`/start /folders /list /read`(只读闭环)。
- `render.rs`:领域 Notification → TG Markdown/HTML 渲染 + 转义(adapter 内部)。
- 会话存储(外部 Redis,`C-REDIS-ONLY-STATE`;**不用 SQLite**)记当前 folder/page。
- 验收:本地 **webhook 形态**(`webhooks-axum` 或纯 axum),测试客户端发命令能读邮件;领域模块不 import teloxide。
### 10.3 阶段 3:发送邮件 + 状态管理(1.5d)
- `domain::jmap::JmapService::send_email`(draft + submission_set)。
- `/send` 用 teloxide 对话 FSM 多步收集 to/subject/body。
- `/flag /unseen`(keywords)。
- 验收:手机发邮件、标记已读。
### 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。
- `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 后由对账补发。
### 10.6 阶段 5:搜索 + 搜索片段 + 打磨(1.5d)
- `/search` + `SearchSnippet/get` 高亮。
- 错误消息打磨、速率/退避实测、`clippy`/`fmt`、README。
### 10.7 总估时
~10.5 人日(不含等待用户确认与真实联调排障)。
---
## 11. 待确认问题(产品/架构类)
> ⚠️ 部署/平台类决策已确认(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账),仅余 `Q-DEP-A`/`Q-DEP-B` 运维待办,见 deployment.md §9。
### 11.1 认证方式(已确认)
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
- 影响:`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)。
### 11.3 部署形态(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。
- 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。
- **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` §10.8。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` §10。
### 11.4 邮件正文呈现
- **Q6**:Telegram 消息用 HTML 还是 MarkdownV2?HTML 转义更可控(推荐 HTML),但 MarkdownV2 视觉更好。
- **Q7**:长邮件策略——已确认"禁止直接发送完整原文,超过阈值(默认 4000)发预览 + `[AI 总结][继续查看原文]` 选项",AI 摘要目标 ~300 字、代码侧不做字符级硬截断(见 §12.3/§12.2)。阈值默认值是否合适见 Q29。
- **Q8**:附件是否在 Telegram 直接下载发送?(Telegram 限 50MB 文件;大附件建议只给下载链接 via `download_url`)。
### 11.5 推送范围与去重
- **Q9**:推送所有新邮件,还是仅特定文件夹/发件人/关键词?(Stalwart 端可用 Sieve 过滤,Bot 端也可二次过滤。)
- **Q10**:是否需要"摘要聚合"(如每早 8 点汇总未读)?由**外部 HTTPS Cron 调度器**触发 `/reconcile` 或新增汇总路由实现(**不在容器内自持定时器**,`C-NO-LONG-CONN`;+0.5d)。
### 11.6 Stalwart 版本/能力
- **Q11**:Stalwart 版本是否启用 Sieve(draft-12)能力?若启用,部分"自动归档/标记"可下沉到服务端 Sieve,Bot 只做通知,减少往返。
- **Q12**:是否需要 `Identity` 多发件身份切换?(若只有一个身份可忽略。)
### 11.7 工程取向
- **Q13**:是否要求 `#![forbid(unsafe_code)]`(与 jmap-client 一致)?建议是。
- **Q15**:日志/指标是否需要对接外部(Loki/Prometheus)?默认只本地 stdout。
- (CI 平台为通用容器 CI,`NG-SERVERLESS-BIND`;具体见 deployment.md。)
---
## 12. AI 辅助能力:架构、确认门槛、失败回退
> **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**:
> 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。
> 2. **查看原文始终 JMAP 直取**(`REQ-VIEW-DIRECT`):`/read` 与任何"看正文"动作走 `JmapService::read_email`,不走 LLM。
> 3. **长邮件禁止直接发送完整原文**(`REQ-LONG-EMAIL`):正文超过阈值(默认 4000 字符,见 §12.3,可调)时,Bot **不直接发送全文**,而是提示"正文较长,请电脑查看"或"选择 AI 总结";AI 摘要目标约 **300 字**,**不做代码侧硬性数学截断**(由 LLM 自然生成 ~300 字)。
> 4. **分析/摘要结果不持久化**(`REQ-ANALYSIS-EPHEMERAL`):不写缓存、不入 Redis、不留磁盘;每次请求即取即弃(无状态天然友好)。
> 5. **附件按需拉取(保留)**(`REQ-ATTACH-ONDEMAND`):不预载;`Blob/get` 仅在用户点击下载时触发,单文件 ≤50MB 发文件,超限给 `download_url` 链接。
> 6. **AI 失败约 3 次后需用户确认的非 AI 回退(保留)**(`REQ-AI-FUSE`):连续 3 次失败 → 熔断 + 向用户弹确认;选择"关闭 60s" 冷却期内直接走规则模板 + 原文直取路径;选择"恢复 AI" 则做一次探测。
> 7. **OpenAI-compatible 环境变量(保留)**(`REQ-LLM-OPENAI-COMPAT`):`LLM_API_KEY / LLM_BASE_URL / LLM_MODEL` 等。
>
> 本节仅做设计,不写业务代码。
### 12.1 模块划分(增量,接 §6)
```
src/domain/llm/ # 零渠道依赖(领域层,见 §5.2)
├── mod.rs # LlmService(门面:summarize / intent / draft;仅确认后调用)
├── client.rs # 包装 axum + reqwest::Client,OpenAI /chat/completions
├── config.rs # 从环境装载 + 校验
├── policy.rs # 熔断/退避/限次(对齐需求 6)
├── fallback.rs # 非 AI 回退:规则模板 + 原文直取路径(对齐需求 1/2)
└── audit.rs # tracing span:prompt hash / token / latency / status
```
### 12.2 OpenAI-compatible 环境变量(需求 7)
```bash
export LLM_API_KEY=...
export LLM_BASE_URL=https://api.openai.com/v1 # 可指向任何 OpenAI-compatible 端点
export LLM_MODEL=... # 如 gpt-4o-mini
# 可选
export LLM_TEMPERATURE=0.2
export LLM_MAX_TOKENS=1024
export LLM_TIMEOUT_SECS=30
export LLM_MAX_RETRIES=3 # 对应需求 6 的"约 3 次"
export LLM_ENABLED=true|false # 一键降级开关
export LLM_ALLOW_NET=true|false # 默认 false;开启才允许 LLM 出网
# 摘要目标长度(写入 prompt 的系统指令,由 LLM 自然控制;代码侧不做字符截断)
export LLM_SUMMARY_TARGET_CHARS=300
```
- 客户端:**axum**(或裸 `reqwest` + `serde`),`POST {LLM_BASE_URL}/chat/completions`,`Authorization: Bearer $LLM_API_KEY`。
- **仅在用户明确要求分析/总结/翻译并确认后**才调用 LLM;此时把**最小必需上下文**(Subject + 正文 + 用户指令)交给 LLM,prompt 系统指令写明"请生成约 300 字中文摘要"。
- 摘要结果**不持久化**(需求 4):不写 Redis、不落盘,处理完即弃;无状态天然友好。
- 默认关闭网出能力(`LLM_ALLOW_NET=false`)降低数据外泄风险;密钥不入仓库;`config::Config` 启动时强校验,缺失且 `LLM_ENABLED=true` → 启动失败或降级为禁用并告警。
- 运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 12.3 正文获取策略与长邮件处理(需求 1/2/3/5)
- **查看原文 = JMAP 直取**:`/read` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。
- **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。
- **长邮件禁止直接发送完整原文**(需求 3):
- 当正文长度 > 阈值(默认 **4000 字符**,由 §12.2 配置,可调),Bot **不发送全文**,而是回复一段简短预览(如 Preview 120 字符 + 发件人/主题/附件概览)并给出选项:
> "邮件正文较长(约 N 字)。在手机上不便完整阅读,建议在电脑端查看。如需在此查看:`[AI 总结]`(约 300 字)`[继续查看原文]`(截断)"
- "AI 总结"路径:经用户确认 → 把正文交 LLM → 生成约 300 字摘要(目标由 prompt 控制,**代码侧不做字符级硬截断**)。
- "继续查看原文"路径:仍 JMAP 直取,发送截断版(明确标注"已截断,完整请电脑查看"),**绝不直接发送完整原文**。
- **附件按需**(需求 5 保留):Telegram 端先给"📎 3 个附件(PDF, 2.1MB)"概览 + 下载按钮;用户点击才触发 `Blob/get` 流式下载 → `Telegram::send_document`,单文件 ≤50MB,超限只给 `download_url` 链接。避免启动期预载全部 blob,减少 JMAP 出站与内存峰值。
- AI 摘要**永远不**把附件内容喂入 prompt(除非后续单独功能开启且白名单限定),保持隐私默认。
### 12.3.1 AI 授权期限与 Redis TTL(`REQ-AI-CONSENT`)
- AI 授权必须由用户明确选择期限;可选临时一次(实现为一次授权窗口,默认 1 小时)、1 小时、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。
- Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。
- 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。
### 12.4 失败检测与熔断(需求 6)
```
LlmService.call(kind: LlmKind, input, ctx)
├─ 调用 axum → Result<Response, LlmErr>
│ 成功 → 正常路径
│ 失败(4xx/5xx/超时/网络/401) → 记入 policy
└─ policy.tick() 计数:
失败次数 >= 3(LLM_MAX_RETRIES) 且 在 TTL 窗口内
→ 触发"熔断 + 需确认回退"(进入半开状态)
```
- 触发后向用户发一条**显式确认消息**(`/llm-fallback` 风格的 inline-button):
> "AI 最近 3 次调用失败,我切换到规则模板回复。是否继续? `[恢复AI(重试)]` `[关闭AI 60s]`"
- 用户选择后:
- "恢复 AI" → 走一次探测请求,成功则重置计数、退出熔断;失败则维持熔断;
- "关闭 60s" → 进入冷却期,期间所有 LLM 调用直接走 fallback 路径。
- 熔断状态**持久化**到 Redis(TTL = 冷却期),无状态化:实例重启/迁移后能从 Redis 恢复;Redis 丢失时退化到进程内短窗口(接受 60s 内的漏判)。Redis 运维与存活窗口见 deployment.md。
### 12.5 非 AI 回退(需求 6,与"原文直取"一致)
- `fallback.rs` 提供与 AI 接口相同的签名(`fn summarize(input:&Input) -> Output`),内部走**规则 + 原文直取**:
- 摘要回退:截取 `Subject + Preview(120 chars)`,不解析 HTML;
- 意图识别回退:用关键字/正则匹配(如"归档"、"删除"、"回复")走确定性分支;
- 草稿/格式化回退:纯模板字符串替换。
- 所有回退响应均**显式标注**"AI 不可用"徽标,避免用户误以为是 AI 生成。
- 回退路径**不经过 LLM 客户端**,保证零 LLM 依赖;且**不触发 3 次熔断逻辑**(熔断只统计 LLM 调用)。
### 12.6 无状态/容器化适配
- **分析/摘要结果不持久化**(需求 4):不写 Redis、不落盘、不做结果缓存;每次请求即取即弃。**删除"摘要结果入 Redis 缓存"方案**——多实例/冷启动只影响重复调用成本,不缓存保证数据最少留存。(与 `C-REDIS-ONLY-STATE` / `SAF-LOG-PURITY` 一致:Redis 上 LLM 相关状态仅熔断计数;**不写** prompt/completion 明文。)
- **熔断计数存 Redis**(`INCR + EXPIRE`),跨实例共享(这是 Redis 上唯一与 LLM 相关的状态)。
- `LLM_SUMMARY_TARGET_CHARS`、`LLM_MAX_RETRIES`、熔断冷却时间通过 config 下发,不写死。
### 12.7 错误处理与可观测性(接 §8)
新增 `BotError::Llm(LlmErr)`;`LlmErr` 细分 `Unauthorized`(401/403)、`RateLimited`(429)、`Timeout`、`BadResponse`、`Transport`。用户消息映射:
- 熔断中 + 未确认 → 静默走回退,附"AI 不可用"徽标;
- 恢复失败 → 再次提示用户确认;
- 审计:`tracing` span `llm.call` 记录 prompt 哈希、response 长度、latency、status,不记录 prompt 明文(合规)。
### 12.8 新增待确认问题
- **Q25**:LLM 提供方是 OpenAI 官方还是自托管/Ollama/vLLM?`LLM_BASE_URL` 是否需要 TLS 互信?出网白名单是否允许?
- **Q26**:`LLM_ALLOW_NET` 默认 false 是否可接受?(即 AI 调用本身也视为敏感出网操作,需用户显式开关。)
- **Q27**:熔断冷却期(60s 还是 5min)与"约 3 次"阈值需确认;"约 3 次"是否含 429 限流?
- **Q28**:附件"按需"指**下载并作为 Telegram 文件回传**,还是**仅给 Stalwart `download_url` 链接**?后者更省流量与成本,前者体验更顺。
- **Q29**:长邮件"禁止直接发送完整原文"的阈值(默认 4000 字符)是否合适?"AI 总结"目标 ~300 字是否满足预期(过短/过长可调整,由 prompt 自然控制)?
- **Q30**:`LLM_ENABLED=false` 时是否**启动即禁用 AI 且不写熔断**(最简降级),还是仍保留探测?推荐前者。
### 12.9 实施计划增量(接 §10)
在阶段 3 与 4 之间插入 **阶段 3.5:LLM 门面 + 回退(1.5d)**
- `llm::client` + `axum`/reqwest 调用;
- `llm::policy` 3 次熔断 + Redis 共享计数(熔断是 Redis 上唯一的 LLM 相关状态;**摘要结果不持久化**);
- `llm::fallback` 规则模板 + 原文直取;
- 长邮件策略:正文 > 4000 字符 → 不发送完整原文,改发预览 + `[AI 总结][继续查看原文]` 选项(对齐 §12.3);
- `telegram/formatting` 增加"AI 不可用"徽标;
- 验收:
- `LLM_ENABLED=true` 且用户明确确认分析 → 正常 ~300 字摘要(不做字符级硬截断);
- 长邮件默认**不发送完整原文**(仅预览 + 选项);
- 模拟 3 次 500 → 弹确认;选"关闭 60s" → 60s 内全走回退并带徽标;
- `LLM_ENABLED=false` → 直接全走回退;
- 查看原文(`/read`)路径**绝不**触发 LLM。
---
## 附:调研依据(可复核)
- `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/stalwart-bot/`:greenfield(首次构建需 `rustup`)。
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。