78 KiB
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 — 给后续 AI coding agent 的硬性安全边界、实现顺序、禁止事项、测试验收与文档引用关系
- docs/deployment.md — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与仍需确认项
本文档只保留与产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至
deployment.md与AGENTS.md。
0. 文档导航
- 任务与范围
- jmap-client 能力分析
- 认证与邮箱操作适配
- Telegram Rust 框架选型
- 整体架构与数据流
- 模块划分
- 配置与安全
- 错误处理与可观测性
- 测试策略
- 分阶段实施计划
- 待确认问题(产品/架构类)
- 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(main 分支,截至本次调研)- 项目目录:
/home/okabe/Repo/messageweave/(工具链要求见 §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 + 对账兜底) | 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)
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 回调(先由管理员通过POST /api/push/register显式注册,见 §7.3)+ 外部 Cron/reconcile对账兜底。Push 订阅不会自动创建,不能把 Push 当作唯一可靠来源。 EventSource/SSE 与 WebSocket 均标记为非目标(NG-POLLING-SSE),仅保留下表作 crate 能力调研参考。
| 通道 | 是否采用 | 备注 |
|---|---|---|
| Push Subscription(HTTP 回调) | 已接入,需显式注册 | 短请求模型,契合无状态 + 无长连接;POST /api/push/register 创建订阅,/push/jmap 自动完成 Stalwart 验证回写 |
| EventSource/SSE | ❌ 非目标 | 长连接,与 C-NO-LONG-CONN 冲突(NG-POLLING-SSE) |
| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 C-NO-LONG-CONN 冲突;且需服务端支持 |
对账兜底:外部 HTTPS Cron 周期调 /reconcile(deployment.md FLOW-RECONCILE),用 Email/changes + Redis sinceState 补差,兼做 Redis 丢失后的游标重建。
3. 认证与邮箱操作适配
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 的理由
- 与 jmap-client 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
- 内建 Dispatcher + UpdateKind 枚举匹配命令,与 Bot 的命令路由天然契合。
- 支持
webhooks-axum(生产 Webhook 形态;不使用长轮询NG-LONG-POLLING)与 Redis 会话存储(记住用户上下文:当前选中的文件夹/分页游标;不用 SQLite)。 throttlefeature 天然契合 Telegram 的 30 msg/s 速率限制。tracingfeature 与本项目观测性统一。macrosfeature 可用#[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 运行模型(短请求,无长连接)
- 单一二进制
message-weave,#[tokio::main]。 - 启动时:
- 加载配置(
config::Config)。 - 构造
JmapService(Client::connect完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(MOD-SINCESTATE)。 - 启动 HTTP 入口(axum,单端口
PORT):/webhook/tg、/push/jmap、/reconcile、/healthz、/ready;其中三条写路径先经SAF-AUTH-*入口鉴权(fail-closed,§7.3),/healthz、/ready为公开轻量探针(SAF-PROBE-PUBLIC),不代表外部依赖已完成端到端验收。 - 启动 Redis Streams worker(后台 task)消费 Push 事件 →
Email/changes→ 通知 → 发往 TG → 推进 sinceState → XACK。 - 不持有任何长连接、不自建定时器(
C-NO-LONG-CONN);对账由外部 HTTPS Cron 触发/reconcile(FLOW-RECONCILE)。
- 加载配置(
- 通知发送与命令处理共享
Arc<JmapService>,内部tokio::sync::RwLock保护可变缓存;跨请求状态一律落外部 Redis(C-REDIS-ONLY-STATE)。 - 多实例与负载均衡(部署形态,
ARCH-LB-WORKER):由于状态全在外部 Redis(C-REDIS-ONLY-STATE)且投递幂等(MOD-DEDUP),同一镜像可跨多个 serverless 平台实例化,前面用免费 Cloudflare Worker 做唯一入口与故障转移(C-LB-SINGLE-REG-URL);/reconcile用 Redis 锁单实例执行(SAF-RECONCILE-LOCK),Streams 用同一消费组自动分摊(MOD-STREAMS-GROUP)。详见 deployment.md §10。领域/渠道逻辑无需改动。 - 生产红线(
C-NO-DB/C-NO-LOCAL-WRITE/C-LOG-STDOUT-ONLY/SAF-LOG-PURITY/C-NO-STATEFUL-RECOVERY,详见 deployment.md §0 / §8.2):- 生产不使用任何数据库(
C-NO-DB):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。 - 禁止本地文件/目录写入(
C-NO-LOCAL-WRITE):无日志文件、无数据文件、无临时缓存、不挂载本地卷。 - 日志只写 stdout/stderr(
C-LOG-STDOUT-ONLY):容器/平台负责采集落盘;禁用文件日志后端。 - 日志与 Redis 写入内容约束(
SAF-LOG-PURITY):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。 - 禁止依赖进程内状态做生产恢复(
C-NO-STATEFUL-RECOVERY):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。
- 生产不使用任何数据库(
- 优雅退出:信号 +
CancellationToken,退出前 flush Redis Streams 待处理条目(不写本地文件,C-NO-LOCAL-WRITE)。 - 当前现状:完成配置引导、HTTP 入口和写入口鉴权(
R1/SAF-AUTH-*,fail-closed);JMAP session、Email/changes、PushSubscription create/update、Redis Streams worker 和/reconcile已接入。/reconcile与 Push 闭环仍需在真实 Stalwart、Redis、Telegram 环境完成端到端验收,不能仅凭本地门禁宣称生产链路已验证。
5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL)
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ 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)
目标设计:命令路由基于对话 FSM(多步交互如 /send、/summarize 确认)。当前实现未引入 teloxide,worker 用 parse_intent 解析为 Intent,AI 授权态走 Redis TTL 而非多步确认态;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,会话/授权态统一走外部 Redis 短期 TTL。
6. 模块划分
目标 cargo 工程结构(阶段0 已落地 main.rs/config.rs/error.rs/domain.rs/channel.rs/notify.rs;下方 jmap/、llm/、channel/telegram/、notify/* 为后续阶段目标,详见 §10):
message-weave/
├── 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:校验 subscription ID + 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,见 §10.0);jmap-client 0.4.2已引入(ARCH-DEPS-STAGE1);redis(0.27) /reqwest(0.13) 亦已引入(ARCH-DEPS-STAGE4,版本以Cargo.toml为准)。teloxide经评估后未引入——Telegram 渠道改用reqwest自研实现。下表保留选型评估的原始记录(含未采纳方案),标「计划」的行已不代表当前计划。
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|---|---|---|---|---|
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 | HTTP 鉴权、全局开关、Push 入队、Email/changes 对账和游标提交 |
领域 Notification → Streams worker | 当前实现已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
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。
⚠️ 下表是
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) |
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) |
| 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)。当前经 Redis 运行参数管理(OutboundConfig.max_retries,回落默认 3,见 src/ai.rs);Config::from_env() 不读取此变量 |
LLM_SUMMARY_TARGET_CHARS |
否 | 300 |
摘要目标字数(REQ-LONG-EMAIL) |
当前
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 密钥管理
- 禁止把 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:按pushSubscriptionId校验 Redis 短期状态;首次 verification 由 JMAPPushSubscription/set回写成功后才放行 StateChange。 比较使用常数时间算法(subtle,防时序侧信道);校验失败一律401且在鉴权通过前不产生任何副作用/状态变更。业务配置完成后,Webhook、Push、Reconcile 和 Push 注册接口的凭证必须有效;启动引导变量缺失时进入配置引导模式,不绕过鉴权(§7.1)。
- 健康探针(
SAF-PROBE-PUBLIC):/healthz(ARCH-HEALTHZ)与/ready为公开探针——无鉴权、只返回健康状态、不含任何敏感信息(不回显配置/密钥/内部错误细节)。/healthz= liveness(进程存活),语义长期稳定。/ready检查配置完整性与 Redis 可访问性;未就绪返回503(标准错误 envelope{"error":"service_unavailable","request_id":<id>}+Retry-After: 30),就绪返回200与就绪报告({"status":"ready","configured":...,...});不执行 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);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)。GET /api/status公开返回{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] },只列缺少的环境变量名称。缺少REDIS_URL或CONFIG_ENCRYPTION_KEY时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。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只读取和写入非敏感运行参数:{ "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;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(代码/依赖行为)
- 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 统一错误枚举
以下为设计目标形态。当前
src/error.rs的BotError仅含Config/Io/Json/State四个变体;Telegram变体未引入(teloxide未使用)。
#[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(直接依赖,非teloxidefeature)+tracing-subscriber(fmt + EnvFilter)。- 关键事件:Push 回调到达/验证/拒绝、Reconcile 拉取与入队、渠道推送成功/失败/重试、LLM 调用耗时与状态等均落
tracing事件(stdout);不记 prompt 明文与密钥。当前未使用命名 span(无#[instrument]/span!),字段级结构化观测待后续补齐。 - 指标(可选
metricscrate):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持有 traitJmapBackend(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 提供
Requestermock,验证send_message被以期望内容调用(仅channel::telegram测试使用)。 - 渠道契约:领域流程用
MockChannel(实现Channel/Notifiertrait)驱动,验证领域输出与渠道渲染解耦、领域层无 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返回200表示进程存活;/ready以200/503表示配置与 Redis 是否就绪——就绪返回就绪报告 JSON,不就绪返回标准错误 envelope(service_unavailable+Retry-After: 30);响应体不含敏感信息。Uptime Kuma 按状态码(/ready期望 200)监控,不受响应体变化影响。 - 渠道解耦:领域模块(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 门禁)
当前实际边界:单端口 axum 入口提供
/webhook/tg、/push/jmap、/reconcile、/healthz、/ready。三条写路径的入口鉴权已 fail-closed 落地(R1/SAF-AUTH-*);/reconcile已使用 JMAPEmail/changes分页、Redisstate:jmap:since和 Redis 单飞锁,只有全部事件入队成功后才推进游标;/healthz为 liveness,/ready仍是轻量探针,不代表 Redis/JMAP 依赖已完成业务验收。
- 实际依赖(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 为准并同步本节。 - 当前不实现 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前必须全部通过:
cargo fmt --check通过(无格式差异)。cargo clippy --all-targets -- -D warnings通过(零告警;禁 crate 级allow)。cargo test通过(含路由/配置最小测试)。- 上述三条在 Debian
rust:1-slim-bookworm容器内执行通过(C-DEBIAN-SLIM)。 - 配置读取为 env-only(无 figment/TOML);
SecretString包裹秘密且Debug不泄密;JMAP_PASSWORD安全访问器保留。 RUN_MODE被实际消费(webhook/reconcile至少显式分派,保持无副作用)。.gitignore存在(排除target/等;不擅自初始化 git)。- 所有新增代码有引用稳定 ID 的必要注释;单
.rs≤ 500 行。
运行监控(GATE-UPTIME-KUMA):
- 使用 Uptime Kuma HTTP(s) Monitor 检查
/healthz(进程存活)和/ready(配置/Redis 就绪),分别期望 HTTP 200;/ready不就绪时返回503与标准错误 envelope(service_unavailable+Retry-After: 30),Uptime Kuma 仍按状态码判定,不受响应体变化影响。 /healthz保持纯 liveness;/ready不执行 JMAP/Telegram 请求或业务副作用。- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
生产红线(贯穿所有阶段):
- 无数据库(
C-NO-DB):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。 - 无本地文件写入(
C-NO-LOCAL-WRITE):无日志文件、无数据文件、无临时缓存、不挂载本地卷。 - 日志只写 stdout/stderr(
C-LOG-STDOUT-ONLY):平台负责采集;禁用文件日志后端。 - 日志/Redis 内容约束(
SAF-LOG-PURITY):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥、JMAP 邮件正文、AI 请求/响应、附件内容。 - 无进程内恢复(
C-NO-STATEFUL-RECOVERY):重启续跑一律走 Redis + JMAP 对账(FLOW-RECONCILE);进程内缓存仅性能优化,丢失安全可重入。
阶段1 推进边界(BOUND-STAGE1):
- 仅在
GATE-P0全项通过后进入阶段1(JMAP 只读)。 - R1(入口真实鉴权)已落地(
SAF-AUTH-RECONCILE/SAF-AUTH-TG-WEBHOOK/SAF-AUTH-JMAP-PUSH):/reconcile校验Authorization: Bearer RECONCILE_TOKEN、/webhook/tg校验X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET、/push/jmap按订阅 ID 校验 Redis 短期验证状态;常数时间比较,失败401且无副作用。缺少启动引导变量时服务进入配置引导模式,不伪造业务成功;业务配置完成后再启用对应入口。 - 仍需注意:本地 CI 门禁通过不等于真实 Stalwart、Redis、Telegram 链路已验收;公网部署前应完成真实端到端测试和外部监控配置。
- 阶段1 只做 JMAP 只读(
list_folders/list_emails/read_email),不引入发送/推送/AI。
10.1 阶段 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及/api/push/register鉴权(SAF-AUTH-*)已落地并通过单测;后续改动不得放宽或绕过鉴权。 #[ignore]真实测试(GATE-G1-JMAP-READONLY):经环境变量驱动,运行命令cargo test -- --ignored jmap::(--ignored是 libtest 参数,必须置于--之后);仅编译不执行用cargo test --no-run(编译含#[ignore]的测试目标)。缺环境时清晰跳过且不泄露任何密钥;CI 默认不跑真机用例,mock 测试继续保留。真机用例通过与否须以实际--ignored运行为准——不得在未运行的情况下声称"真机通过"。- 验收:
cargo test(mock)全绿;cargo test -- --ignored jmap::(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
10.2 阶段 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 已实现能力:实时推送(Push 回调 + Streams worker + 外部 Cron 对账)
notify::push_handler:POST /api/push/register接受显式 HTTPS callback URL 并创建订阅;接收 Stalwart 的PushVerification,通过 JMAPPushSubscription/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、Redis 和 Telegram 环境完成端到端验证,不能将此处目标当作已验证事实。
10.5.1 Redis Streams 事件保障边界(FLOW 运行语义)
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 条)
- 读批 → 逐条
process→ XACK。 - 处理中写入
delivery:inflight(60s)作租约;成功后写delivery:committed(7d)。 - 崩溃于
inflight租约窗口内的条目,由另一实例经 XAUTOCLAIM(idle 阈值 300s)回收重试 —— 至多重复、不丢。 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 EXTTL 下限收紧为.max(1);XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由ack_malformed经XACK移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。 - 未排期待办(阶段 5「搜索 + 搜索片段 + 打磨」)见
docs/todo.md「未排期」。
10.6 总估时
~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 回调 + 外部 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池化)。 - 部署形态 = 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/(非本 Rust 二进制),部署见docs/deployment.md§10.8。信任模型为透传(后端仍 fail-closed 校验,SAF-LB-PASSTHRU),后端间共享同一组 secret(C-LB-SHARED-SECRETS),/reconcileRedis 锁单实例(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:是否要求
#?建议是。 - Q15:运行监控采用 Uptime Kuma HTTP(s) Monitor 检查
/healthz与/ready;不引入 Prometheus、Exporter 或额外指标端口。详细配置见 deployment.md。- (CI 平台为通用容器 CI,
NG-SERVERLESS-BIND;具体见 deployment.md。)
- (CI 平台为通用容器 CI,
12. AI 辅助能力:架构、确认门槛、失败回退
用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准):
- AI 仅在被明确要求并确认后接触正文(
REQ-AI-CONFIRM/REQ-AI-EXTERNAL-CONSENT):只有当用户明确发起"分析/总结/翻译"意图并再次确认后,才允许把正文喂给 LLM;仅当用户明确允许时才向外部 AI 发送邮件正文(默认不外发);其余场景(包括查看原文)一律不经 AI,由 JMAP 直取。- 查看原文始终 JMAP 直取(
REQ-VIEW-DIRECT):/read与任何"看正文"动作走JmapService::read_email,不走 LLM。- 长邮件禁止直接发送完整原文(
REQ-LONG-EMAIL):正文超过阈值(默认 4000 字符,见 §12.3,可调)时,Bot 不直接发送全文,而是提示"正文较长,请电脑查看"或"选择 AI 总结";AI 摘要目标约 300 字,不做代码侧硬性数学截断(由 LLM 自然生成 ~300 字)。- 分析/摘要结果不持久化(
REQ-ANALYSIS-EPHEMERAL):不写缓存、不入 Redis、不留磁盘;每次请求即取即弃(无状态天然友好)。- 附件按需拉取(保留)(
REQ-ATTACH-ONDEMAND):不预载;Blob/get仅在用户点击下载时触发,单文件 ≤50MB 发文件,超限给download_url链接。- AI 失败约 3 次后需用户确认的非 AI 回退(保留)(
REQ-AI-FUSE):连续 3 次失败 → 熔断 + 向用户弹确认;选择"关闭 60s" 冷却期内直接走规则模板 + 原文直取路径;选择"恢复 AI" 则做一次探测。- 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)
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 直取,发送截断版(明确标注"已截断,完整请电脑查看"),绝不直接发送完整原文。
- 当正文长度 > 阈值(默认 4000 字符,由 §12.2 配置,可调),Bot 不发送全文,而是回复一段简短预览(如 Preview 120 字符 + 发件人/主题/附件概览)并给出选项:
- 附件按需(需求 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 授权必须由用户明确选择期限;可选
临时一次(一次授权窗口,3600 秒)、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。触发词按原文包含匹配(临时/一次/今天/7天/直到我撤销/长期,以及/ai on、/ai yes、/ai off),不支持英文别名。 - Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用与所选期限完全相同的
SET EX秒数;不使用隐式 30 天默认值,也不保存正文或摘要。 - 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。
/ai off立即删除 key。
12.4 失败检测与熔断(需求 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 不可用"徽标;
- 恢复失败 → 再次提示用户确认;
- 审计:
tracingspanllm.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::policy3 次熔断 + 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-clientmain 分支: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:
teloxide0.17(features 列表)、jmap-client元数据。 stalwartlabs/mail-servermain 分支: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/(工具链要求见 §10.0 与 AGENTS.md)。 - 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。