docs: 同步 /search 落地与 ⑥-a/⑥-b 后的断言、重锚 worker.rs/reference.md
⑥-d 文档同步(01a0e760-b6f5),无代码改动。 /search 落地(bfe0fd8) - design.md 新增 §5.7「邮件搜索」:Filter::text + SearchSnippet/get、高亮降级、 纯文本出站、失败映射、截断上限、正文级高亮在锁定版本做不到(含原因) - design.md §2.3 接口表、§3.2 search_emails 方法行、§5.6 Intent 6 种、§10.5 待办收口 - reference.md §8「Not implemented」:删掉「/search 未实现 / SearchSnippet 零命中」 两条已失效断言,改为「正文级高亮不可能」这一真实缺口 - reference.md §1.2 补 /search 触发词(仅前缀匹配,不授予授权、不写 Redis 键) - AGENTS.md 阶段 5 标注搜索已完成 ⑥-a/⑥-b 遗留的陈旧断言(本轮清掉) - design.md §12.8 速率:429 从「不做专门的自动退避」改为按 parameters.retry_after 退避(截断到 60s 预算,缺字段回退指数退避 250ms→4s) - design.md §10.6 已知边界 + §9:XAUTOCLAIM 阈值从「批大小 × 300s」改为按运行配置 推导,公式、300s 下限与 6h 上限指向 reference.md §6.3 - reference.md §6.3 重写阈值公式,新增 §6.4 搜索上限常量表 重锚 - worker.rs:569 → 613(help_message,⑥-c 插入后下移) - AGENTS.md 版本基线 → bfe0fd8;retired.md 验证基线 → bfe0fd8; retired.md read_batch 锚点 343→384 / 379→425 / 376→394-401 - design.md §3.5 交叉引用 §12.9 → §12.8 - SAF-DEBUG-ALLOWLIST 保持「不改代码」原状,按产品决策未动
This commit is contained in:
parent
bfe0fd88aa
commit
c746ead184
|
|
@ -8,7 +8,7 @@
|
|||
>
|
||||
> **跨文档引用一律用稳定 ID**(`C-` / `NG-` / `MOD-` / `FLOW-` / `REQ-` 等),**不用章节号(§x.y)**;定义点位置与一句话说明见 [§7 跨文档引用索引](#7-跨文档引用索引表)。
|
||||
>
|
||||
> **版本基线 v3(2026-09-26,本轮 ④+⑤):代码 `afa6cec`,文档 `399b269`(⑤ 收口)。**
|
||||
> **版本基线 v3(2026-09-26,本轮 ④+⑤,代码随 ⑥ 推进):代码 `bfe0fd8`,文档 `399b269`(⑤ 收口)。**
|
||||
> 本轮新增 `src/debug.rs` 远程联调面与 6 个 `*DEBUG*` ID(§2 第 19 条、§5 测试清单、§7 六行),文档侧同步落位
|
||||
> `docs/deployment.md` §2.1、`docs/reference.md` §3/§4、`README` 中英双版;各文档的行号锚点以该基线为准,代码变动后须同步重锚。
|
||||
|
||||
|
|
@ -86,9 +86,9 @@
|
|||
+ `read_email` 多 part 原文拼接(`REQ-JMAP-RAW-MULTIPART`)
|
||||
阶段 2 渠道适配骨架(Channel/Notifier/MessageAdapter + 首个渠道 Telegram;CHAT_ALLOWLIST 强制 + throttle;/start /folders /list /read)
|
||||
阶段 3 发送 + 状态(send_email draft+submission;/send FSM;/flag)
|
||||
阶段 3.5 LLM 门面 + 回退(见 design.md §12.9)
|
||||
阶段 3.5 LLM 门面 + 回退(见 design.md §12.8)
|
||||
阶段 4 实时推送(Push 回调 + Redis Streams worker + 外部 Cron 对账 + sinceState→Redis)
|
||||
阶段 5 搜索 + 搜索片段 + 打磨
|
||||
阶段 5 搜索 + 搜索片段 + 打磨(搜索 + 搜索片段已完成,`bfe0fd8`,见 design.md §5.7)
|
||||
```
|
||||
|
||||
> **阶段门禁**:`GATE-P0`(阶段0 P0 门禁,8 项)与 `BOUND-STAGE1`(阶段1 推进边界)定义见 design.md §10.0。**未过 `GATE-P0` 不得进入阶段1**;阶段0 业务体为无副作用占位,**不应暴露公网**。**注意**:三条写路径的**入口鉴权 `R1` 已在阶段0 落地**(`SAF-AUTH-*`,fail-closed),阶段1 不再重复实现,也**禁止放宽/绕过**。
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ client_ws/ WebSocket 客户端(feature = "websockets")
|
|||
| 读附件 | `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` 高亮 | |
|
||||
| 搜索 | `email_query`(`Filter::text`)+ `SearchSnippet/get` 高亮 | `Filter` 是 serde 单标签枚举,**无 comparator 语法**;`SearchSnippet/get` 只返回 `emailId`/`subject`/`preview`,**无 `bodyProperties`/`parts`**,正文级高亮在锁定版本 `0.4.2` 做不到(降级为纯 ID 列表) |
|
||||
| 实时通知(Push + 对账兜底) | Push HTTPS 回调 → `StateChange`;外部 Cron 调用 `/reconcile` 使用 `Email/changes` 补差 | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);Push 不是唯一可靠来源 |
|
||||
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ WebSocket(...) ws 错误(feature 开启时)
|
|||
| `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` |
|
||||
| `search_emails(account_id, query, limit)` | → `Result<Vec<SearchResult>, JmapError>` | `email_query`(`Filter::text`,`limit` 封顶 100)取 ID + `SearchSnippet/get` 取高亮;后者不支持(`unknownMethod`/超时)时**降级**返回空 snippets 而非报错,由调用方渲染纯 ID 列表 |
|
||||
|
||||
> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。
|
||||
|
||||
|
|
@ -283,6 +283,19 @@ TG /read 3 → handler 取会话里的 folder+page 游标
|
|||
|
||||
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
|
||||
|
||||
### 5.7 邮件搜索(`/search`,`bfe0fd8` 落地)
|
||||
|
||||
`/search <关键词>` 与中文前缀 `搜索`/`查找`/`检索`(**仅前缀匹配**,避免"帮我搜一下…"被劫持成搜索)触发 `Intent::Search`,经 `JmapService::search_emails(account_id, query, limit)` 走 `email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取 `subject`/`preview` 高亮。
|
||||
|
||||
关键边界:
|
||||
- **高亮降级**:`SearchSnippet/get` 不支持(`unknownMethod`)或超时时返回空 snippets 而非报错;渲染降级为纯 ID 列表(`高亮片段暂不可用,以下为匹配的邮件 ID`),不发明片段。
|
||||
- **纯文本出站**:`<mark>` 高亮标记经 `strip_mark_tags`(大小写不敏感、未闭合标记丢弃)剥离后,再 `unescape_html_entities` 单趟解码;先剥标签再解码实体,防止邮箱正文里字面 `<mark>`(经服务器转义为 `<mark>`)被还原成真标签而误删。`subject`/`preview` 分别有 120/160 字符截断上限(`truncate_chars`,与正文截断同工具)。
|
||||
- **失败映射**:JMAP 侧失败 → `SearchReply::Retry` → `process_telegram` 返回 `Err(())` → 协调器 503 + `Retry-After` 重试(与 `/reconcile`、其他 JMAP 路径一致),**不 panic、不向用户回错误栈**。
|
||||
- **空查询**:`/search`(无关键词)→ 引导提示"请提供搜索关键词,例如:/search 发票",不发请求。无匹配 → `没有找到匹配「…」的邮件。`。
|
||||
- **每条命中带 `email_id`**,可直接接 `/summary <email_id>` 进入 AI 摘要流程。
|
||||
- **正文级高亮在锁定版本做不到**:jmap-client `0.4.2` 的 `SearchSnippet` 只建模 `emailId`/`subject`/`preview`,无 `bodyProperties`/`parts`,按 RFC 8621 §5 的正文级 `body: String[Id]` 不被该 crate 建模且(无 `deny_unknown_fields`)被 serde 静默丢弃。每部分正文高亮需绕过 crate 直发原始 JMAP,当前不实现。
|
||||
- **无新环境变量**;缺配置时 `/search` 给友好报错。
|
||||
|
||||
---
|
||||
|
||||
## 6. 模块划分
|
||||
|
|
@ -329,12 +342,12 @@ message-weave/
|
|||
| `domain` + `domain::jmap::client` | **jmap-client 0.4.2** | JMAP 只读语义;`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 代码已实现,待真实 Stalwart 端到端验证(`cargo test -- --ignored jmap::`) |
|
||||
| `state` | redis 0.27 | Redis 读写:配置/开关/会话/TTL 键(`C-REDIS-ONLY-STATE`) | Redis mock | 已实现 |
|
||||
| `channel` | reqwest 0.13;jmap-client | `Channel` / `Notifier` / `MessageAdapter`,邮件与 TG 双渠道(事件→领域 Command;领域 Notification→渲染) | mock HTTP | 已实现(`src/channel.rs`,Telegram 为 `reqwest` 自研) |
|
||||
| `worker` | — | `parse_intent` → `Intent`,命令路由与 AI 授权判定 | 表驱动纯单测 | 已实现 |
|
||||
| `worker` | — | `parse_intent` → `Intent`(6 种,含 `Search(query)`:`/search` + `SearchSnippet/get` 高亮渲染),命令路由与 AI 授权判定 | 表驱动纯单测 | 已实现 |
|
||||
| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 |
|
||||
| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
|
||||
| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 |
|
||||
|
||||
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:见 `docs/roadmap.md`「代码缺口」(3 条)与本文件 §10 的未排期待办。
|
||||
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:见 `docs/roadmap.md`「代码缺口」(2 条:多实例重复投递残余窗口、`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -397,7 +410,7 @@ message-weave/
|
|||
- `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。
|
||||
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。
|
||||
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。
|
||||
- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 不做专门的自动退避处理。
|
||||
- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。
|
||||
|
||||
### 7.3.1 配置管理 API
|
||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||
|
|
@ -581,7 +594,7 @@ pub enum BotError {
|
|||
- `src/channel.rs` 定义 Channel / Notifier / MessageAdapter 抽象 + 领域 Command/Notification 类型。
|
||||
- Telegram 装配用 `reqwest` 直发 `https://api.telegram.org/bot{token}/sendMessage`(**未引入 teloxide**;评估记录见 `docs/retired.md`)。
|
||||
- 入站:`POST /webhook/tg` 校验 secret token → 按 `update_id` 去重(`dedup:tg:{update_id}`)→ 入 Redis Streams → 2xx;出站为 Telegram 唯一用到的 Bot API 端点。
|
||||
- 意图路由:`src/worker.rs` 的 `parse_intent` 只解析 5 种意图 —— `Help` / `Consent { ttl, label }` / `Summary(email_id)` / `Query` / `Unknown`,全部走自然语言触发词(AI 授权词见 `docs/reference.md` 的 AI 授权态一节)。
|
||||
- 意图路由:`src/worker.rs` 的 `parse_intent` 解析 6 种意图 —— `Help` / `Consent { ttl, label }` / `Summary(email_id)` / `Search(query)` / `Query` / `Unknown`,全部走自然语言触发词(AI 授权词见 `docs/reference.md` 的 AI 授权态一节)。`/search` 详见 §2.3、§5.7;中文搜索词只做**前缀匹配**(`搜索/查找/检索` + `/search`),避免覆盖授权与摘要意图。
|
||||
- 渲染在 adapter 内部:领域 Notification → TG Markdown/HTML + 转义。
|
||||
- 会话存储走外部 Redis(`C-REDIS-ONLY-STATE`;**不用 SQLite**)。
|
||||
- 验收:本地 webhook 形态,测试客户端发消息能得到回复;领域模块不导入任何渠道 SDK 类型。
|
||||
|
|
@ -624,7 +637,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
|
|||
|
||||
1. 读批 → 逐条 `process` → XACK。
|
||||
2. 处理中写入 `delivery:inflight`(60s)作租约;成功后写 `delivery:committed`(7d)。
|
||||
3. 崩溃于 `inflight` 租约窗口内的条目,由另一实例经 XAUTOCLAIM 回收重试(空闲阈值 = 批大小 × 单条上限 300s,**不是**固定 300s)—— 至多重复、不丢。
|
||||
3. 崩溃于 `inflight` 租约窗口内的条目,由另一实例经 XAUTOCLAIM 回收重试(空闲阈值由运行超时配置推导,公式与上下限见 `docs/reference.md` §6.3)—— 至多重复、不丢。
|
||||
4. `retry_or_dlq`(`state.rs`):重试计数(`max_attempts.max(1)`)未到上限留在源流重试;达到上限则以**单个 Lua 脚本**原子地 `INCR`+`XADD`(入 DLQ)+`XACK`(源流确认),保证不会出现"源已 ACK 但既不在源也不在 DLQ"的缝隙。
|
||||
|
||||
**告警边界(当前实现,见 §8.3 监控约定)**
|
||||
|
|
@ -635,14 +648,14 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
|
|||
|
||||
**已知边界(当前实现仍存在,见 `docs/roadmap.md`)**
|
||||
|
||||
- 多实例重复投递窗口:XAUTOCLAIM 空闲阈值已由固定 300s 改为「批大小 × 单条上限 300s」;单实例不受影响,仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领,导致**重复投递(仅重复,不丢)**。
|
||||
- 多实例重复投递窗口(**已收口,`6c99ce5`**):XAUTOCLAIM 空闲阈值不再按固定值缩放,改由运行超时配置推导——`(max_retries + 1) × (jmap + telegram + llm 超时) × 2` 为单条上限,再乘批大小,下限 300s、上限 6h(见 `docs/reference.md` §6.3)。提前认领窗口在单实例与多实例部署下均关闭;单实例不受影响,多实例最多重复、不丢。
|
||||
|
||||
**审计意见 → 收口(2026-09-26)**
|
||||
|
||||
- 【应修-2】入队失败时 dedup 释放 best-effort 曾可能造成 24h 静默丢事件 → 已修复为 `claim_dedup_and_enqueue`(Lua 原子:`SET NX EX` 成功才 `XADD`),claim 与入队之间无中间失败窗口。
|
||||
- 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义本身仍需真实 Stalwart 复验(见 `docs/roadmap.md`)。
|
||||
- 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。
|
||||
- 未排期待办(阶段 5「搜索 + 搜索片段」)见 `docs/roadmap.md`「代码缺口」。
|
||||
- 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。
|
||||
|
||||
### 10.6 总估时
|
||||
~10.5 人日(不含等待用户确认与真实联调排障)。
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
> **This file is the single source of truth for verifiable facts.**
|
||||
> When any other document disagrees with this one, this one wins.
|
||||
>
|
||||
> **Verified against commit `afa6cec`.** Line numbers in this file were read from that
|
||||
> **Verified against commit `bfe0fd8`.** Line numbers in this file were read from that
|
||||
> commit with the working tree clean.
|
||||
>
|
||||
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
|
||||
|
|
@ -26,11 +26,11 @@ Conventions used throughout:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `config:business` | No EX | `set_business_config` (state.rs:530, key at state.rs:534); first-time write via `SET NX` in `initialize_business_config` (state.rs:545) | `get_business_config` (state.rs:519) |
|
||||
| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:538); `SET 1 NX` on first-time init (state.rs:558) | `business_config_revision` (state.rs:593) |
|
||||
| `config:outbound` | No EX | `set_outbound_config` (state.rs:506) | `get_outbound_config` (state.rs:490) |
|
||||
| `config:enabled` | No EX | `set_enabled` (state.rs:611) | `is_enabled` (state.rs:602) |
|
||||
| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:568, key at state.rs:571) | `admin_session_valid` (state.rs:580); cleared by `revoke_admin_session` (state.rs:589) |
|
||||
| `config:business` | No EX | `set_business_config` (state.rs:576, key at state.rs:580); first-time write via `SET NX` in `initialize_business_config` (state.rs:591) | `get_business_config` (state.rs:565) |
|
||||
| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:584); `SET 1 NX` on first-time init (state.rs:604) | `business_config_revision` (state.rs:639) |
|
||||
| `config:outbound` | No EX | `set_outbound_config` (state.rs:552) | `get_outbound_config` (state.rs:536) |
|
||||
| `config:enabled` | No EX | `set_enabled` (state.rs:657) | `is_enabled` (state.rs:648) |
|
||||
| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:614, key at state.rs:617) | `admin_session_valid` (state.rs:626); cleared by `revoke_admin_session` (state.rs:635) |
|
||||
|
||||
Notes:
|
||||
|
||||
|
|
@ -48,13 +48,13 @@ Notes:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:458; key at state.rs:460, `EX` at state.rs:466) | `ai_consent_until` (state.rs:472); cleared by `clear_ai_consent` (state.rs:481) |
|
||||
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:504; key at state.rs:506, `EX` at state.rs:512) | `ai_consent_until` (state.rs:518); cleared by `clear_ai_consent` (state.rs:527) |
|
||||
|
||||
The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX`
|
||||
carries the same duration, so the key removes itself (state.rs:461-466).
|
||||
carries the same duration, so the key removes itself (state.rs:508-513).
|
||||
|
||||
Trigger words are exact Chinese literals with **no English aliases** — the in-app help text
|
||||
enforces this itself (`worker.rs:569`):
|
||||
enforces this itself (`worker.rs:613`):
|
||||
|
||||
| Input | TTL | Label |
|
||||
|---|---|---|
|
||||
|
|
@ -67,15 +67,19 @@ enforces this itself (`worker.rs:569`):
|
|||
`1小时` is a **category label, not a trigger word**: typing `1小时` alone grants nothing.
|
||||
Consent never auto-renews.
|
||||
|
||||
The same `parse_intent` (worker.rs:522, `Intent` at worker.rs:504) routes `/search <关键词>`
|
||||
and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to
|
||||
`Intent::Search`. Search grants no consent and writes no Redis key.
|
||||
|
||||
### 1.3 Delivery pipeline
|
||||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:241, `XADD` at state.rs:243); call sites notify.rs:203 (TG webhook) and notify.rs:859 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:360, :428) |
|
||||
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:282, `XADD` at state.rs:284); call sites notify.rs:203 (TG webhook) and notify.rs:859 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:360, :428) |
|
||||
| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:395, `claim_dedup` at notify.rs:396 | `release_dedup` on completion (notify.rs:409, 416, 425) |
|
||||
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:374, `claim_dedup` at notify.rs:408 | `dedup_exists` before send (notify.rs:375) |
|
||||
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:434, `INCR` state.rs:438, `EXPIRE` state.rs:439) | reclaim path |
|
||||
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:441; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** |
|
||||
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:480, `INCR` state.rs:484, `EXPIRE` state.rs:485) | reclaim path |
|
||||
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:487; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** |
|
||||
|
||||
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
|
||||
pending-entries list (PEL), which Redis maintains internally.
|
||||
|
|
@ -89,7 +93,7 @@ acknowledgement.
|
|||
|
||||
The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` /
|
||||
`stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ
|
||||
append and source acknowledgement are atomic (state.rs:438-442). No code path reads the DLQ
|
||||
append and source acknowledgement are atomic (state.rs:484-488). No code path reads the DLQ
|
||||
back — replay is an operator action, not a service feature.
|
||||
|
||||
### 1.4 Idempotency and rate limits
|
||||
|
|
@ -99,7 +103,7 @@ back — replay is an operator action, not a service feature.
|
|||
| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:198, `claim_dedup` at notify.rs:199, `release_dedup` at :208 | — |
|
||||
| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:854, `claim_dedup` at notify.rs:855, `release_dedup` at :864; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — |
|
||||
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:815, `claim_dedup` at notify.rs:816 (`push_verify_rate_limited` at :819) | — |
|
||||
| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:629; key at state.rs:632, `SET` without `EX`) | `get_reconcile_state` (state.rs:621) |
|
||||
| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:675; key at state.rs:678, `SET` without `EX`) | `get_reconcile_state` (state.rs:667) |
|
||||
|
||||
`state:jmap:since` is the only state key that survives indefinitely without an explicit
|
||||
delete, by design: it is the reconciliation cursor, and expiring it would force a full
|
||||
|
|
@ -123,11 +127,11 @@ duplicate. Source comment, notify.rs:904-905:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:639, called at notify.rs:833) | `push_subscription_verified` (state.rs:663) |
|
||||
| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:672) | current-subscription lookup |
|
||||
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:679, key at state.rs:687); pending at notify.rs:960, verified at notify.rs:845, disabled at notify.rs:1031 | **none** |
|
||||
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:732, key at state.rs:739, `EX` at state.rs:742; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:721); removed by `remove_push_subscription_for_callback` (state.rs:748) |
|
||||
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:712) | orphan sweep |
|
||||
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:685, called at notify.rs:833) | `push_subscription_verified` (state.rs:702) |
|
||||
| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:715) | current-subscription lookup |
|
||||
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:725, key at state.rs:733); pending at notify.rs:960, verified at notify.rs:845, disabled at notify.rs:1031 | **none** |
|
||||
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:778, key at state.rs:785, `EX` at state.rs:787; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:767); removed by `remove_push_subscription_for_callback` (state.rs:794) |
|
||||
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:751) | orphan sweep |
|
||||
|
||||
**Two distinct keys that are frequently confused. Read this before editing either.**
|
||||
|
||||
|
|
@ -139,7 +143,7 @@ duplicate. Source comment, notify.rs:904-905:
|
|||
They use the same digest but are separate keys with unrelated lifetimes and purposes.
|
||||
|
||||
**Annotation: `push:subscription:{id}:status` is write-only by design.**
|
||||
The source carries an explicit note at state.rs:169:
|
||||
The source carries an explicit note at state.rs:210:
|
||||
|
||||
> `push:subscription:{id}:status` is currently write-only
|
||||
|
||||
|
|
@ -358,7 +362,7 @@ endpoint.
|
|||
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:55; validated notify.rs:411 |
|
||||
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:56; validated notify.rs:412 |
|
||||
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:57; validated notify.rs:413 |
|
||||
| `max_retries` | 3 | hard cap 5 | default state.rs:58; rejected at notify.rs:414; re-clamped at channel.rs:87 |
|
||||
| `max_retries` | 3 | hard cap 5 | default state.rs:58; rejected at notify.rs:414; re-clamped at channel.rs:138 |
|
||||
|
||||
There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
||||
`config:outbound` and is bounded at 5 regardless of what is written.
|
||||
|
|
@ -377,7 +381,24 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
|||
|
||||
### 6.3 Idle threshold
|
||||
|
||||
XAUTOCLAIM's idle threshold is **batch size × 300 s**, not a fixed 300 s.
|
||||
XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:98):
|
||||
|
||||
```
|
||||
count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2
|
||||
```
|
||||
|
||||
floored at the legacy 300 s single-event ceiling (state.rs:65) and capped at 6 h
|
||||
(state.rs:75). The floor is also the fallback when the config cannot be read, so a
|
||||
Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at
|
||||
stake, never loss.
|
||||
|
||||
### 6.4 Search limits
|
||||
|
||||
| Constant | Value | Source |
|
||||
|---|---|---|
|
||||
| `SEARCH_LIMIT` | 10 | worker.rs:618 |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:620 |
|
||||
| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:622 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -410,10 +431,12 @@ and only `script-src`, `style-src` and `connect-src` re-open a same-origin chann
|
|||
|
||||
Recorded here so that references elsewhere cannot be mistaken for shipped features.
|
||||
|
||||
- `/search` is not implemented. `SearchSnippet` and `search_snippet` have zero occurrences in
|
||||
`src/`.
|
||||
- There is no `Search` variant in the worker's `Intent` enum, so the search path cannot be
|
||||
entered even indirectly.
|
||||
- `/search` **is** implemented (`bfe0fd8`, `worker.rs` / `jmap_service.rs`). What is *not*
|
||||
possible: **body-level** snippets. jmap-client `0.4.2` only exposes `emailId`/`subject`/
|
||||
`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so
|
||||
per-part body highlight cannot be modelled through the locked crate. Search degrades to
|
||||
`subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design
|
||||
note in `docs/design.md` §5.7.
|
||||
- The Cloudflare Worker contains no Rust: `cloudflare-worker/src/` holds only `index.js`,
|
||||
`lb.js`, `backends.js` and `health.js`. Any `*.rs` path written under `cloudflare-worker/`
|
||||
is a documentation error, not a source file.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
> 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。
|
||||
> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。
|
||||
> 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。
|
||||
> 验证基线:`afa6cec`。
|
||||
> 验证基线:`bfe0fd8`。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:717`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) |
|
||||
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
|
||||
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:384`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`;
|
||||
> 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。
|
||||
> 验证基线:`afa6cec`。门禁命令见 `docs/deployment.md` 的 Gate 一节。
|
||||
> 验证基线:`bfe0fd8`。门禁命令见 `docs/deployment.md` 的 Gate 一节;当前 `GATE-P0` 全绿(62 passed / 0 failed / 1 ignored)。
|
||||
|
||||
## 阶段目标
|
||||
|
||||
|
|
@ -17,11 +17,13 @@
|
|||
- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。
|
||||
- **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。
|
||||
|
||||
## 代码缺口(已定位,待实现)
|
||||
## 代码缺口(已定位)
|
||||
|
||||
- **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。
|
||||
- **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。
|
||||
- **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。
|
||||
前 3 条已实现(见各自 commit),仅 `SAF-DEBUG-ALLOWLIST` 按产品决策保留不改。真正的阻塞是真实环境依赖,见下文「阻塞」区。
|
||||
|
||||
- **`/search` + `SearchSnippet/get` 高亮(已完成,`bfe0fd8`)** — `Intent::Search` 变体 + `JmapService::search_emails`(`email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取高亮);高亮经 `strip_mark_tags`/`unescape_html_entities` 渲染成 Telegram 纯文本;snippet 不支持时降级为纯 ID 列表;`subject`/`preview` 有 120/160 字符截断上限;每条命中带 `email_id` 可直接接 `/summary`。设计见 `docs/design.md` §2.3、§3.2、§5.7。
|
||||
- **Telegram 429 退避(已完成,`f4cae00`)** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)现在按 429 的 `Retry-After` 退避,缺该头时指数退避。
|
||||
- **多实例重复投递窗口(已完成,`6c99ce5`)** — XAUTOCLAIM 空闲阈值不再按固定 300s 单条上限缩放,改为按运行配置推导(超时预算 × 安全系数 × 批大小),单实例与多实例部署下的提前认领窗口关闭。
|
||||
- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、本轮不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。
|
||||
|
||||
## 验收待办
|
||||
|
|
|
|||
Loading…
Reference in New Issue