diff --git a/docs/opengaps.md b/docs/opengaps.md index e92c4e5..bae08d4 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -1,29 +1,107 @@ -# Open Gaps(`docs/opengaps.md`) +# 未完成项(Open Gaps) -> 只登记**仍未完成、仍未验证**的事项。已实现能力见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`; -> 被移除、废弃、明确拒绝的能力见 `docs/retired.md`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 +本文件只列**仍未完成 / 仍未验证**的事项。已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。 + +## 你要做的事 + +代码侧**没有**待你实现的项。四项阻塞里,两项需要你跑一条 curl,一项只需要你告诉我一个事实,一项需要你腾一个停机窗口。 + +| # | 事项 | 谁做 | 你要动手吗 | +| --- | --- | --- | --- | +| 1 | Telegram `setWebhook` | 你 | 跑 1 条 curl,然后发一条消息 | +| 2 | Stalwart `PushSubscription` | 你 | 跑 1 条 curl(注册由本服务自己向 JMAP 发起) | +| 3 | 回调公网映射 | 你 | 只回答一个问题:用哪个组件做映射 | +| 4 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 | +| 5 | 单飞锁 TTL 实测 | 我 | 不需要的,依赖 #2 成功后我自己跑 | +| 6 | 允许名单收紧 | 你 | 仅当你要收紧时改业务配置,代码不用动 | ## 阶段目标 -`GATE-P0` 已过(fmt / clippy / check / test,Debian 容器内)。真实邮箱与真实 Telegram Bot 的**出站与查询方向**已在真机联调通过(见下文各条的「已验证」括注)。 -剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`** 与 **`PushSubscription` 回调链路**,消掉下面 4 条阻塞项。 +剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`**,以及在 Stalwart 侧走通 `PushSubscription` 回调链路,消掉下面 4 条阻塞项。出站查询方向(JMAP 只读 → Telegram 回帖)、`/search`、出站限流退避、多实例去重均已完成并真机通过。 -## 阻塞(需要真实环境,当前无法验证) +## 阻塞 -- **真实 Stalwart `PushSubscription`** — `Email/changes` 与 `Email/query` 搜索已在真实账号跑通(2026-09-28:`POST /reconcile` 持久化了 `baseline:` 游标,而该游标只在 `reconcile()` 返回 `Ok(new_state)` 之后写入(`notify.rs:331`),即 `fetchChanges` 端到端成功)。**未验证**:账号角色是否具备 push 权限、Stalwart 回调重试次数、TTL/对账间隔的匹配。**阻塞原因**:无真实推送回调可观察。禁止使用测试账号或伪造结果。 +### 1. 真实 Telegram 回调(Telegram → 本服务方向) -- **真实 Telegram 回调(Telegram→本服务方向)** — 应用侧入站 secret 头校验 + chat 白名单(`notify.rs:225-263`)、出站 `sendMessage`(`channel.rs`)、出站重试预算与重试耗尽终局错误(`channel.rs:182`)已在真实 Bot 上验证(2026-09-28:`POST /debug/notify` 投递成功;伪造 update 全链路「入队→worker→JMAP `Email/query` 搜索→回帖」通过;以上两条均经用户确认收到)。**未验证**:真实的 Telegram→本服务回调从未到达——向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现,**目前尚未执行**,因此 Telegram 侧既不知道本服务的 webhook URL,也不持有 secret 值。另:长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:需在 Telegram 侧执行 `setWebhook`(`url=<公网地址>/webhook/tg`、`secret_token=<业务配置里的值>`,两者必须一致)并触发一次真实回调。 +出站方向已验证,但**Telegram → 本服务从未被真实流量验证过**:到目前为止 `/webhook/tg` 收到的全部是伪造 update。Telegram 侧还从未收到过 `setWebhook`,所以 Telegram 既不知道回调地址,手里也没有 secret。 -- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批;2026-09-28 在真实账号上跑通 `fetchChanges` 并拿到 `baseline:` 游标,但 `newState` 是否表示"全部待报变更之后"仍需**停机积压后恢复**的场景才能判定,否则积压边界仍无法确证。**阻塞原因**:无可控的真实积压场景。 +**你要做的:** -- **Push callback 公网映射 + 单飞锁 TTL** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 +1. 登记 webhook(`` = 业务配置里的 `telegram_webhook_secret`): + ```bash + curl -sS "https://api.telegram.org/bot/setWebhook" \ + -d url=https://messageweave-eu-1.motofans.club/webhook/tg \ + -d secret_token= \ + -d 'allowed_updates=["message"]' + ``` + 不传 `allowed_updates` 时默认订阅全部更新类型;显式收窄到 `message` 与本服务只解析 `message.text` 一致。 +2. 确认已登记: + ```bash + curl -sS "https://api.telegram.org/bot/getWebhookInfo" + ``` + 看 `result.url` 等于上面那个地址。**Telegram 不回显 `secret_token`**,所以 secret 写对没有只能靠第 3 步反过来验。 +3. 用允许名单里的 chat id 发一条 `/help`,然后告诉我「发了」。 + +我来收尾:`POST /api/worker` 排空队列并确认回帖。如果没收到,`getWebhookInfo` 里的 `last_error_message` 与 `last_error_date` 是最快的定位线索。 + +**轮换注意:** 在 SPA 改了 `telegram_webhook_secret` 而**没有**重跑 `setWebhook`,Telegram 手里就是旧值——之后所有真实回调都会被 401 拒掉,**不报错、无告警,只是静默收不到消息**。改了就重跑第 1 步。 + +### 2. 真实 Stalwart `PushSubscription` + +`PushSubscription/set` 的验证往返只在本地单测里跑过(测试指向一个永不连通的地址);生产环境从未注册过订阅,所以 Stalwart 从未发过一次真实 push 回调,`/push/jmap` 的验证握手分支从未被真实请求走过。 + +**先说清分工:** 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。调用即完成 `PushSubscription/set` create 并限制订阅类型;**验证码由 Stalwart 生成、经回调送回来,本服务自动完成验证,不接受 SPA 里配置的验证码**(`src/notify.rs:1163`)。所以你不需要去拿验证码,你只需要让回调地址能被 Stalwart 访问到。 + +**你要做的:** + +1. 确认回调地址公网可达,且 Stalwart 出站能访问到它(防火墙/代理放行)。是 Stalwart **回调**你,不是你去连它。 +2. 注册: + ```bash + curl -sS -X POST "https://messageweave-eu-1.motofans.club/api/push/register" \ + -H "Authorization: Bearer " \ + -H "content-type: application/json" \ + -d '{"callback_url":"https://messageweave-eu-1.motofans.club/push/jmap"}' + ``` + 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`(TTL 900s,`src/notify.rs:1240`),在等 Stalwart 回来验证。 +3. Stalwart 的验证请求到达后,本服务自动验证并置 `verified`(TTL 300s,`src/notify.rs:1125`)。日志在 stdout(`SAF-LOG-STDOUT-ONLY`)。 +4. 确认已落库:再跑一次第 2 步,返回里多出 `"idempotent":true` 即说明订阅已持久化。也可反向确认——`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 + +**失败怎么读:** `400 invalid_request` = 回调地址不是 HTTPS,或 URL 里带了用户名/密码(本服务禁止这两者,`src/notify.rs:1174`);`409 conflict` = 注册单飞锁还被上一次请求占着,稍后重试;`503` = 业务配置此刻不可用或 JMAP 请求失败,锁已释放,可重试。 + +**取消注册:** 带 `{"callback_url":"..."}` 打 `POST /api/push/disable`。 + +### 3. Push callback 公网映射 + 单飞锁 TTL + +**这部分你不需要跑命令,只需要回答一个问题:公网映射由哪个组件负责。** 当前这个测试部署前面没有 Cloudflare Worker,我不知道该在哪里放行 `/push/jmap`。候选是 `cloudflare-worker/` 里已有的实现(从未部署)、nginx,或其他 ingress。 + +TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过: + +| 键 | 用途 | TTL | 位置 | +| --- | --- | --- | --- | +| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1178` | +| `lock:reconcile` | 对账锁(长对账中每 90s 续租) | 300s | `src/notify.rs:289` / `:302` | +| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1095` | +| `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` | +| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1134` | + +我来做的:订阅注册成功(第 2 项)之后,触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 + +### 4. `Email/changes` 的 `newState` 语义 + +`jmap-client` 的 `fetchChanges` 在服务器无法回放旧增量时会返回 `newState`,要求客户端重新基线化。本代码库的 `since` 游标处理只在测试服务器上跑过,**尚未出现一次服务器要求重新基线化的真实场景**。 + +**你要做的:** 腾一次停机窗口——在邮箱还有新邮件进入的时候把本服务停掉,停到 Stalwart 的增量历史过期为止(保留时长由你的 Stalwart 配置决定;窗口要长于它)。停机期间**不要**跑 `POST /reconcile`。 + +我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。 ## 代码缺口(已定位) -仅 1 条,按产品决策保留不改;其余已实现,见各自 commit 与 `docs/reference.md`。真正的阻塞是上面的真实环境依赖。 +仅 1 条,按产品决策保留不改——**这不是待修项,也不需要你做任何事**。 -- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。 +- **`SAF-DEBUG-ALLOWLIST`** — `POST /debug/notify` 的收件人校验在允许名单为空时**无条件放行**任意 `chat_id`(`src/debug.rs:233-235`:`!allowlist.is_empty() && !allowlist.contains(&chat_id)`)。空名单即「未启用收紧」,是刻意行为。要收紧:在业务配置里把允许名单填上,代码不用改。要改代码:先推翻这个决策。 -## 后续(可选项,产品决策后开启) +## 后续(多账号) -- **多账号支持**(`REQ-SINGLE-ACCOUNT`)— 当前单账号由产品决策固定,非阶段目标。 +单账号由产品决策固定,不在阶段目标内。当前 JMAP 登录与 `Email/query` 均为单一邮箱账号。 + +**需要你决定:** 是否需要多账号。要的话告诉我,我先把 `REQ-SINGLE-ACCOUNT` 的约束范围拆出来再动代码。