From c746ead184cbc0eb002fd661c74ab07226483bf6 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Mon, 28 Sep 2026 21:45:05 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20/search=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=E4=B8=8E=20=E2=91=A5-a/=E2=91=A5-b=20?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E6=96=AD=E8=A8=80=E3=80=81=E9=87=8D=E9=94=9A?= =?UTF-8?q?=20worker.rs/reference.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⑥-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 保持「不改代码」原状,按产品决策未动 --- AGENTS.md | 6 ++-- docs/design.md | 31 ++++++++++++++------ docs/reference.md | 75 +++++++++++++++++++++++++++++++---------------- docs/retired.md | 4 +-- docs/roadmap.md | 12 ++++---- 5 files changed, 83 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 330a2e6..90194fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 不再重复实现,也**禁止放宽/绕过**。 diff --git a/docs/design.md b/docs/design.md index 1a990a3..4e6ad47 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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` | `email_query`(Filter::and[…]) + `SearchSnippet/get` | +| `search_emails(account_id, query, limit)` | → `Result, 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`),不发明片段。 +- **纯文本出站**:`` 高亮标记经 `strip_mark_tags`(大小写不敏感、未闭合标记丢弃)剥离后,再 `unescape_html_entities` 单趟解码;先剥标签再解码实体,防止邮箱正文里字面 ``(经服务器转义为 `<mark>`)被还原成真标签而误删。`subject`/`preview` 分别有 120/160 字符截断上限(`truncate_chars`,与正文截断同工具)。 +- **失败映射**:JMAP 侧失败 → `SearchReply::Retry` → `process_telegram` 返回 `Err(())` → 协调器 503 + `Retry-After` 重试(与 `/reconcile`、其他 JMAP 路径一致),**不 panic、不向用户回错误栈**。 +- **空查询**:`/search`(无关键词)→ 引导提示"请提供搜索关键词,例如:/search 发票",不发请求。无匹配 → `没有找到匹配「…」的邮件。`。 +- **每条命中带 `email_id`**,可直接接 `/summary ` 进入 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/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_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 人日(不含等待用户确认与真实联调排障)。 diff --git a/docs/reference.md b/docs/reference.md index f9bbae5..701b292 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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. diff --git a/docs/retired.md b/docs/retired.md index 3899c85..09e5b62 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -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`「已知边界」同句已删 | --- diff --git a/docs/roadmap.md b/docs/roadmap.md index e2e1154..9fe5b1e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.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`。 ## 验收待办