# 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) JMAP problem-details Server(String) Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…) Set(SetError) /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` | `mailbox_query` + `mailbox_get`(缓存 role→id) | | `list_emails(folder, page)` | → `Vec` | `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` | `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`,内部 `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 `(长邮件分支) ``` 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 `; - `/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 `,返回 `{ "session": "", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: ```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` 存 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`)。 - 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` 不重复推送。 - 正文转义:含 `