docs: document the notification rendering contract and sweep anchors

New reference.md 5.4 pins the user-visible contract: notifications are three
metadata lines only (From / Subject / Received), never the body and never an
LLM summary; the mailbox display form and its fallback literals; the
timezone field, its 16-zone whitelist, the 422 on anything else, and why DST
is not tracked. Registered as REQ-TIMEZONE-DISPLAY in charter 8.

deployment 5 gains the TIMEZONE row, including the SPA section and the
reason DST zones cannot be added.

Opengaps 1 drops two blockers that are verified done and moves the remaining
two to a "what you do / what I do" checklist.

Corrected doc claims that the line-number checker cannot reach:

- reference 6.2 and the Redis key table cited notify.rs 240/253 and
  252/253 for lock TTL and heartbeat; the lock is acquired at :289 and
  renewed at :302.
- The Redis key table's dedup:tg / dedup:jmap / ratelimit / stream call-site
  anchors all pointed into the ready() handler and into unrelated config
  helpers. Re-pointed at the real statement lines.
- Both Retry-After helpers were anchored at error_response 321-329 and
  error_response_with_id 1088-1094; the real definitions are 370-378 and
  1368-1375, and the readiness path passes retry at :210, not :162.
- design claimed summarize() is also called by notify.rs for new-mail
  notifications. There is exactly one call site, the /summary branch in
  worker.rs; notifications never call the LLM.
- design described list_emails as requesting an explicit property list and
  returning Vec<EmailSummary>; the code passes no properties and returns
  Vec<EmailMetadata>.
- retired listed 6 intents but only 5; worker.rs:533 for /search is :553.
- Config is 7 fields now that timezone is mirrored into it.

The anchor audit now flags any citation that lands on a bare brace, paren or
comma - the shape these drifted references had.
This commit is contained in:
anonsaber 2026-09-29 21:48:04 +08:00
parent 61de5f985f
commit 953a4db6f0
6 changed files with 102 additions and 56 deletions

View File

@ -237,6 +237,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 |
| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:109 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:109 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 |
| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 |
| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |
| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | | `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 |
| `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 | | `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 |

View File

@ -74,11 +74,11 @@
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
`src/main.rs:217-264`。 `src/main.rs:217-264`。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1340-1342`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1625`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
**鉴权** **鉴权**
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:446`),因此令牌是**常数时间比较**(`src/notify.rs:1136`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:495`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
@ -99,7 +99,7 @@
**三条约束,部署时务必确认** **三条约束,部署时务必确认**
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 - **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1142`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。 - **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1422`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。
- **不在 Worker 白名单内,只能直连 origin**:网关的 15 条安全路由(`cloudflare-worker/src/backends.js:9-25`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:81-82`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 - **不在 Worker 白名单内,只能直连 origin**:网关的 15 条安全路由(`cloudflare-worker/src/backends.js:9-25`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:81-82`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
@ -184,7 +184,7 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1163`)。 后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1163`)。
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
(`src/notify.rs:1174`)。 (`src/notify.rs:1175-1177`)。
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 - 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁
(`src/notify.rs:1178` / `src/notify.rs:1186`),撞锁返回 409 `conflict`。 (`src/notify.rs:1178` / `src/notify.rs:1186`),撞锁返回 409 `conflict`。
@ -300,6 +300,7 @@ services 表里已存的值填进 payload,再用 `hoststack.yaml` 声明的值
| Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 | | Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 |
| `LLM_*` | 可选 | OpenAI-compatible 业务配置字段(`REQ-LLM-OPENAI-COMPAT`);其中 `LLM_MAX_RETRIES` **不是**业务配置字段而是 Redis 运行参数(回落默认见 `docs/reference.md` §6.1);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_*` | 可选 | OpenAI-compatible 业务配置字段(`REQ-LLM-OPENAI-COMPAT`);其中 `LLM_MAX_RETRIES` **不是**业务配置字段而是 Redis 运行参数(回落默认见 `docs/reference.md` §6.1);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
| `TIMEZONE` | 默认 `Asia/Shanghai` | 通知里的收件时间按此时区渲染(`REQ-TIMEZONE-DISPLAY`,wire 键为小写 `timezone`)。在配置页第 `04` 节「通知显示 / Notification display」的选择器里设置,不是环境变量。**只接受 16 个固定偏移时区**(`Etc/UTC`、`Africa/Cairo`、`Europe/Istanbul`、`Africa/Nairobi`、`Asia/Dubai`、`Asia/Karachi`、`Asia/Kolkata`、`Asia/Bangkok`、`Asia/Ho_Chi_Minh`、`Asia/Shanghai`、`Asia/Hong_Kong`、`Asia/Taipei`、`Asia/Singapore`、`Asia/Manila`、`Asia/Tokyo`、`Asia/Seoul`),不在表内时 `PUT /api/business-config` 返回 **422** 且旧配置继续生效。**不跟踪夏令时**:离线构建没有 IANA tz 数据库(`chrono-tz` 不可用),所以只收录全年无夏令时的区域;需要伦敦/纽约这类区域请自行换算成对应的固定偏移区域 |
AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
@ -412,19 +413,28 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret 两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret
存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有 存储取同一个值即可(下方 k8s 示例即此做法)。`/worker` 只认 Bearer token、**没有
admin-session 兜底**(`src/notify.rs:386`),这是有意的:cron 不需要先登录拿 session。 admin-session 兜底**(`src/notify.rs:386`),这是有意的:cron 不需要先登录拿 session。
token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`,再从热更新快照 token 轮换后**无需重启**——worker 处理函数开头先 `refresh_business_config`(`src/notify.rs:381`),
取值(`src/notify.rs:1425`)。 再从热更新快照取值(`:385`,快照由 `auth_snapshot` 读取)。
`/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在), `/worker` 不在 Worker 转发白名单里(`SAFE_ROUTES`:`/reconcile` 在内、`/worker` 不在),
走 Worker 会被 404 `route not forwarded` 挡掉,必须打后端 origin。见 §10.4。 走 Worker 会被 404 `route not forwarded` 挡掉,必须打后端 origin。见 §10.4。
最小示例(curl 直连 Worker): 最小示例(手动烟测,打印状态码;`/reconcile` **不接受请求体**,游标存在服务端 Redis 里):
```bash ```bash
curl -fsS -X POST "https://<你的平台URL>/reconcile" \ curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "https://<你的平台URL>/reconcile" \
-H "Authorization: Bearer <RECONCILE_TOKEN>" \ -H "Authorization: Bearer <RECONCILE_TOKEN>" \
-H "content-type: application/json" \ --max-time 60
-d '{"since":"<STATE>"}' ```
`/worker` 也先手动跑一遍,但要打**后端 origin 直连地址**(它不在 Worker 转发白名单里,见 §10.4)。空请求体是合法的,等价于取默认 10 条,不用带 `-d`:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "https://<后端origin直连地址>/worker" \
-H "Authorization: Bearer <WORKER_TOKEN>" \
--max-time 60
``` ```
最小调度(两步一条 cron,顺序不能反): 最小调度(两步一条 cron,顺序不能反):
@ -490,11 +500,10 @@ spec:
| --- | --- | --- | | --- | --- | --- |
| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 | | `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 | | `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 |
| `/reconcile` 400 | `since` 不是合法游标 | 检查请求体,不要盲重试 | | `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl;空请求体合法,等价于取默认 10 条 |
| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl | | `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:284`) | 检查 cron 的 token 值;重启不解决 |
| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:279`) | 检查 cron 的 token 值;重启不解决 | | `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:393`) | 同上;`/worker` 无 admin-session 兜底 |
| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:386`) | 同上;`/worker` 无 admin-session 兜底 | | `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:289` 取锁,`src/notify.rs:345` 返回) | 直接跳过本次 tick,不要重试 |
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:289`) | 直接跳过本次 tick,不要重试 |
| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) | | 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) |
重试建议: 重试建议:
@ -516,6 +525,7 @@ spec:
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**。Push 是主路径,对账是兜底; - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**。Push 是主路径,对账是兜底;
但**排空不是兜底项**——没有它,邮件只会一直进队列,一行通知都不会发出来。 但**排空不是兜底项**——没有它,邮件只会一直进队列,一行通知都不会发出来。
- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
`src/notify.rs:302`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `src/notify.rs:302`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
@ -721,7 +731,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:586` 读、开关写入在 `:958`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(队列排空端点,由外部调度周期性调用而非人工触发,Bearer 鉴权,见 §6.3.1)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:676` 读、开关写入在 `:1048`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -151,7 +151,7 @@ WebSocket(...) ws 错误(feature 开启时)
| Bot 动词 | 封装函数 | 内部 JMAP | | Bot 动词 | 封装函数 | 内部 JMAP |
|---|---|---| |---|---|---|
| `list_folders()` | → `Vec<Folder>` | `mailbox_query` + `mailbox_get`(缓存 role→id) | | `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]) | | `list_emails(folder, page)` | → `Vec<EmailMetadata>` | `email_query`(anchor 分页) + `email_get` 不传 `properties`(取服务器默认全量,含 `From`;列表页也要显示发件人) |
| `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` | | `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) | | `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) |
| `set_flag(id, keyword)` | → () | `email_set` keywords | | `set_flag(id, keyword)` | → () | `email_set` keywords |
@ -250,7 +250,7 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state}) Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK → 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id → worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ email_get([From, Subject, Preview, ReceivedAt, HasAttachment]) → read_email 封装 email_get(Subject, Preview, From, TextBody, BodyValues, Size, ReceivedAt, HasAttachment)→ EmailMetadata
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文) → notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
→ channel::telegram::TelegramClient::send_notification(chat, notification)(内部渲染文本后经 send_text 发出) → channel::telegram::TelegramClient::send_notification(chat, notification)(内部渲染文本后经 send_text 发出)
→ 更新 sinceState(写外部 Redis)→ XACK → 更新 sinceState(写外部 Redis)→ XACK
@ -335,7 +335,7 @@ message-weave/
| 模块 | 依赖 | 输出 | 可测性 | 现状 | | 模块 | 依赖 | 输出 | 可测性 | 现状 |
|---|---|---|---|---| |---|---|---|---|---|
| `config` | secrecy;ring(`session_digest` SHA-256) | `Config` 结构(6 字段,全部有真实读取方) | 纯函数,易测 | 已实现(Redis 业务配置 serde 反序列化 + `validate_nonblank` fail-closed;启动期 env 由 `main.rs` 直读,无 figment/TOML) | | `config` | secrecy;ring(`session_digest` SHA-256) | `Config` 结构(7 字段,全部有真实读取方) | 纯函数,易测 | 已实现(Redis 业务配置 serde 反序列化 + `validate_nonblank` fail-closed;启动期 env 由 `main.rs` 直读,无 figment/TOML) |
| `error` | — | `BotError`(§8.1,4 个变体) | 单测 | 已实现 | | `error` | — | `BotError`(§8.1,4 个变体) | 单测 | 已实现 |
| `domain` + `domain::jmap::client` | **jmap-client 0.4.2** | JMAP 只读语义;`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 代码已实现,待真实 Stalwart 端到端验证(`cargo test -- --ignored jmap::`) | | `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 | 已实现 | | `state` | redis 0.27 | Redis 读写:配置/开关/会话/TTL 键(`C-REDIS-ONLY-STATE`) | Redis mock | 已实现 |
@ -729,7 +729,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
``` ```
配置装载在 `src/config.rs` 的 `LlmConfig`,运行时参数在 `src/state.rs` 的 `OutboundConfig`(经 `RuntimeConfigProvider` 下发)。 配置装载在 `src/config.rs` 的 `LlmConfig`,运行时参数在 `src/state.rs` 的 `OutboundConfig`(经 `RuntimeConfigProvider` 下发)。
LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。 LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 / 策略 / 审计分层——熔断、规则回退、审计 span 全部未实现(见 §12.4–12.7)。
调用方只有 `worker.rs` 的 `MetadataWorker`(已授权摘要)和 `notify.rs`(新邮件通知)。 `summarize()` 只有**一个**调用点:`worker.rs` 的 `/summary` 已授权摘要分支。新邮件通知**不调用 LLM** —— `send_notification` 只渲染 `From:` / `Subject:` / `Received:` 三行元数据,正文永不外发(见 docs/reference.md §5.4)。
### 12.2 LLM 变量清单(当前实现) ### 12.2 LLM 变量清单(当前实现)

View File

@ -18,7 +18,7 @@
上一轮真机联调已经关掉了两条阻塞: 上一轮真机联调已经关掉了两条阻塞:
- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。此前 `/webhook/tg` 收到的全部是伪造 update,Telegram 侧从未收到过 `setWebhook`,所以这个方向一直没有被真实流量验证过。 - **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。此前 `/webhook/tg` 收到的全部是伪造 update,Telegram 侧从未收到过 `setWebhook`,所以这个方向一直没有被真实流量验证过。
- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1139`(JMAP 回调入队)与 `src/worker.rs:374`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 - **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1140`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。
两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。
@ -48,9 +48,9 @@ TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1178` | | `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1178` |
| `lock:reconcile` | 对账锁(长对账中每 90s 续租) | 300s | `src/notify.rs:289` / `:302` | | `lock:reconcile` | 对账锁(长对账中每 90s 续租) | 300s | `src/notify.rs:289` / `:302` |
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1095` | | `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1096` |
| `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` | | `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` |
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1134` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1135` |
我来做的:触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 我来做的:触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。

View File

@ -71,7 +71,7 @@ substring matches anywhere in the message (`worker.rs:546-566`, `:581-586`).
`1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**: `1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**:
typing a label alone grants nothing. Consent never auto-renews. typing a label alone grants nothing. Consent never auto-renews.
The same `parse_intent` (worker.rs:523, `Intent` at worker.rs:505) routes `/search <关键词>` The same `parse_intent` (worker.rs:543, `Intent` at worker.rs:525) routes `/search <关键词>`
and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to
`Intent::Search`. Search grants no consent and writes no Redis key. `Intent::Search`. Search grants no consent and writes no Redis key.
@ -79,9 +79,9 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:204 (TG webhook) and notify.rs:860 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:374) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:361, :428) | | `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:253 (TG webhook) and notify.rs:1140 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:394) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:409-410) |
| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:396, `claim_dedup` at notify.rs:397 | `release_dedup` on completion (notify.rs:410, 416, 425) | | `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:445, `claim_dedup` at notify.rs:446 | `release_dedup` on completion (notify.rs:459, 466, 475) |
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:375, `claim_dedup` at notify.rs:409 | `dedup_exists` before send (notify.rs:376) | | `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:424, `claim_dedup` at notify.rs:458 | `dedup_exists` before send (notify.rs:425) |
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:481, `INCR` state.rs:485, `EXPIRE` state.rs:486) | reclaim path | | `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:481, `INCR` state.rs:485, `EXPIRE` state.rs:486) | reclaim path |
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:488; name built at notify.rs:429 (`max_attempts` 3, same call) | **nothing in code reads it** | | `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:488; name built at notify.rs:429 (`max_attempts` 3, same call) | **nothing in code reads it** |
@ -104,9 +104,9 @@ back — replay is an operator action, not a service feature.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:199, `claim_dedup` at notify.rs:200, `release_dedup` at :208 | — | | `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:248, `claim_dedup` at notify.rs:249, `release_dedup` at :258 | — |
| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:855, `claim_dedup` at notify.rs:856, `release_dedup` at notify.rs:865; same key rebuilt at worker.rs:361 and claimed with `claim_dedup_and_enqueue` at worker.rs:374 | — | | `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:1135, `claim_dedup` at notify.rs:1136, `release_dedup` at notify.rs:1145; same key rebuilt at worker.rs:390 and claimed with `claim_dedup_and_enqueue` at worker.rs:394 | — |
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:816, `claim_dedup` at notify.rs:817 (`push_verify_rate_limited` at :819) | — | | `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:1096, `claim_dedup` at notify.rs:1097 (`push_verify_rate_limited` at :1107) | — |
| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:676; key at state.rs:679, `SET` without `EX`) | `get_reconcile_state` (state.rs:668) | | `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:676; key at state.rs:679, `SET` without `EX`) | `get_reconcile_state` (state.rs:668) |
`state:jmap:since` is the only state key that survives indefinitely without an explicit `state:jmap:since` is the only state key that survives indefinitely without an explicit
@ -117,7 +117,7 @@ re-baseline on the next restart.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:240) | `renew_lock` heartbeat 90 (notify.rs:253) | | `lock:reconcile` | 300 | `acquire_lock` (notify.rs:289) | `renew_lock` heartbeat 90 (notify.rs:302) |
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) | | `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) |
**Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest
@ -185,13 +185,13 @@ field. `request_id` is always present.
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
string `"30"`: string `"30"`:
- `error_response` (`notify.rs:321-329`, inserted at `:324`) sets it **only when its `retry` - `error_response` (`notify.rs:370-378`, inserted at `:374`) sets it **only when its `retry`
flag is true**. The readiness failure path (`notify.rs:162`) passes `retry = true`, so `/ready` flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready`
is covered by that rule rather than by a special case. is covered by that rule rather than by a special case.
- `error_response_with_id` (`notify.rs:1088-1094`, inserted at `:1090`) sets it - `error_response_with_id` (`notify.rs:1368-1375`, inserted at `:1371`) sets it
**unconditionally**. It is used only for the push register/disable failures **unconditionally**. It is used only for the push register/disable failures
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:953`, `:969`, `:986`, `push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`,
`:1023`), which are always `503`. `:1304`), which are always `503`.
`GET /api/status` is the exception that reports setup state with a non-envelope body. `GET /api/status` is the exception that reports setup state with a non-envelope body.
@ -199,12 +199,12 @@ string `"30"`:
## 3. Backend routes ## 3. Backend routes
All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap_config` All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap`
(`notify.rs:1295`), with the routes wired at `notify.rs:1323-1338`. The three static SPA routes (`notify.rs:1575`), with the routes wired at `notify.rs:1604-1622`. The three static SPA routes
in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and
are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`, are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`,
the production router at `src/main.rs:180`. The setup-mode router at `src/main.rs:76` mounts only the production router at `src/main.rs:195-205`. The setup-mode router at `src/main.rs:76` mounts
three of this table — `/healthz`, `/ready` and `/api/status` — and no business or admin route; only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or admin route;
§5.1 explains why that is deliberate. §5.1 explains why that is deliberate.
| Method | Path | Handler | | Method | Path | Handler |
@ -230,10 +230,10 @@ three of this table — `/healthz`, `/ready` and `/api/status` — and no busine
The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is
requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN` requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN`
is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`, is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`,
`src/notify.rs:1340-1342`); otherwise none of these routes exist and requests fall through to `src/notify.rs:1625`); otherwise none of these routes exist and requests fall through to
axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`, axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`,
checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production
`worker_authorized` so the comparison is constant time (`src/notify.rs:1136`). `worker_authorized` so the comparison is constant time (`src/notify.rs:1416`).
| Method | Path | Handler | | Method | Path | Handler |
|---|---|---| |---|---|---|
@ -254,7 +254,7 @@ Response conventions:
completeness (`setup_missing` empty), Redis reachability, and two upstream probes — completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
`GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and
`GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms `GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms
(`notify.rs:74`) and run **in parallel** (`tokio::join!`, `notify.rs:148`), so the worst case (`notify.rs:122`) and run **in parallel** (`tokio::join!`, `notify.rs:196`), so the worst case
is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields
are real probe results. The probes are plain reusable functions (`probe_jmap_session`, are real probe results. The probes are plain reusable functions (`probe_jmap_session`,
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
@ -309,7 +309,7 @@ backend pool returns **503** rather than passing the request through (index.js:8
`GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the `GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the
admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled` admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled`
reads it at `web/config.js:586`, the toggle writes it at `:958`); both calls are reads it at `web/config.js:676`, the toggle writes it at `:1048`); both calls are
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded
`/api/admin/session` pair. `/api/admin/session` pair.
@ -320,9 +320,9 @@ directly, or the bootstrap path must be added to the gateway allowlist.
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to `POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
proxy: the callback URL is supplied by the client in the request body (`notify.rs:875`, proxy: the callback URL is supplied by the client in the request body (`notify.rs:875`,
validated as a URL at :891), and every push subscription record is written to and read back validated as a URL at :1172), and every push subscription record is written to and read back
from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback` from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`
at `notify.rs:918`), so it does not matter which backend instance the worker picks. Push at `state.rs:768`), so it does not matter which backend instance the worker picks. Push
registration therefore no longer requires hitting a specific backend address — see registration therefore no longer requires hitting a specific backend address — see
deployment.md §10.5. deployment.md §10.5.
@ -348,9 +348,9 @@ and reconcile traffic share one router and reconcile is a standalone `POST /reco
endpoint, so the variable never changed any runtime behaviour — there is now nothing to set. endpoint, so the variable never changed any runtime behaviour — there is now nothing to set.
**Missing a required variable does not crash the process.** It logs a warning and serves **Missing a required variable does not crash the process.** It logs a warning and serves
`router_configuration_setup` (`notify.rs:1200-1223`), which mounts only `/api/status`, `router_configuration_setup` (`notify.rs:1500-1520`), which mounts only `/api/status`,
`/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not `/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not
registered at all: `admin_token` is empty, so `constant_time_eq` (`notify.rs:1136`) would reject registered at all: `admin_token` is empty, so `constant_time_eq` (`notify.rs:1416`) would reject
every candidate forever, and `MemoryState` (`state.rs:875`) could not persist a bootstrap every candidate forever, and `MemoryState` (`state.rs:875`) could not persist a bootstrap
write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401` write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401`
that reads as "retry with a better credential". The container stays up and answers the status that reads as "retry with a better credential". The container stays up and answers the status
@ -414,14 +414,14 @@ The `JMAP_*` family is exactly three names. In production the account id arrives
`accountId` request-body field (notify.rs:1122) and is held on the client as `accountId` request-body field (notify.rs:1122) and is held on the client as
`account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these `account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these
four names are inside the `#[ignore]` real-server smoke test at four names are inside the `#[ignore]` real-server smoke test at
src/domain/jmap/client.rs:514-526, which is not part of the production configuration surface. src/domain/jmap/client.rs:517-526, which is not part of the production configuration surface.
The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
`TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable. `TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable.
**Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup **Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup
(main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by (main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by
`worker_authorized` (notify.rs:446; compare at notify.rs:1136) at the top of both `worker_authorized` (notify.rs:495; compare at notify.rs:1136) at the top of both
`POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is `POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is
only compared against the request bearer — never echoed, logged, or stored. The session only compared against the request bearer — never echoed, logged, or stored. The session
issued by `/api/admin/session` is a freshly generated random 32-byte hex token issued by `/api/admin/session` is a freshly generated random 32-byte hex token
@ -430,6 +430,41 @@ issued by `/api/admin/session` is a freshly generated random 32-byte hex token
it, if any, authenticates the Redis connection and is not the credential for any HTTP it, if any, authenticates the Redis connection and is not the credential for any HTTP
endpoint. endpoint.
### 5.4 Notification rendering
`send_notification` (`channel.rs`) is the only function that renders a notification into
Telegram text, and it renders exactly three metadata lines — never the body, and never an
LLM summary:
```text
From: Zhang San <zhang@example.com>
Subject: Q4 budget
Received: 2026-09-28 09:15
```
`From:` is the JMAP `EmailAddress` in standard display form: `Name <address>` when the
sender carries a display name, the bare address otherwise. Both `From:` and `Subject:`
fall back to a literal (`unknown` / `(no subject)`) only when JMAP did not return the
property.
`Received:` is rendered at notification time, not stored as text. The server keeps the
JMAP `receivedAt` as a Unix second and converts it to wall-clock time in the configured
timezone using `%Y-%m-%d %H:%M` — 24-hour clock, minute precision, no AM/PM, no seconds.
When `receivedAt` is absent, or falls outside the representable instant range for the zone,
the literal `unknown` is shown rather than a clipped timestamp.
The timezone is a business-configuration field, `timezone` (`REQ-TIMEZONE-DISPLAY`): an
IANA identifier, default `Asia/Shanghai`. Only 16 zones are accepted, every one of them
without daylight saving transitions — `Etc/UTC`, `Africa/Cairo`, `Europe/Istanbul`,
`Africa/Nairobi`, `Asia/Dubai`, `Asia/Karachi`, `Asia/Kolkata`, `Asia/Bangkok`,
`Asia/Ho_Chi_Minh`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Singapore`,
`Asia/Manila`, `Asia/Tokyo`, `Asia/Seoul`. Any other identifier is rejected with **422**
rather than silently falling back to a guess. This is a deliberate limitation: the offline
build has no IANA tz database (`chrono-tz` is unavailable), so each zone resolves to a
fixed offset and never tracks a transition. None of the sixteen needs one, which is why
`Asia/Shanghai` can be the default. Like the rest of business configuration it is edited
in the SPA and applied hot, with no restart.
--- ---
## 6. Outbound and runtime parameters ## 6. Outbound and runtime parameters
@ -454,7 +489,7 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 | | `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 |
| `RECONCILE_BUDGET` | 20 s | worker.rs:251 | | `RECONCILE_BUDGET` | 20 s | worker.rs:251 |
| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 | | `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 |
| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:240 / 252 | | Lock TTL / heartbeat | 300 s / 90 s | notify.rs:289 / 302 |
`max_changes` is a function parameter, not a constant. `max_changes` is a function parameter, not a constant.
@ -510,8 +545,8 @@ 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. Recorded here so that references elsewhere cannot be mistaken for shipped features.
- `/search` **is** implemented (`bfe0fd8`). Adapter: `search_emails` at `worker.rs:652` and - `/search` **is** implemented (`bfe0fd8`). Adapter: `Intent::Search` at `worker.rs:553` and
at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:319`. at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:320`.
What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes 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 `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 per-part body highlight cannot be modelled through the locked crate. Search degrades to

View File

@ -87,7 +87,7 @@
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 | | 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:885`)内联处理回调 URL 与验证码回写 | 无 | | `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:860`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
@ -96,7 +96,7 @@
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:553`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 | | 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
@ -124,7 +124,7 @@
| `required_secret` / `required_nonblank` / `env_bool` | 已删除助手函数 | 仅被上述解析器调用,随其一并删除 | 必填语义改由 `validate_nonblank` 在 Redis 业务配置路径承担(8 处调用) | | `required_secret` / `required_nonblank` / `env_bool` | 已删除助手函数 | 仅被上述解析器调用,随其一并删除 | 必填语义改由 `validate_nonblank` 在 Redis 业务配置路径承担(8 处调用) |
| `RUN_MODE` 变量 + `Config.run_mode` 字段 + 启动校验块 | 已删除标识符 | 两种取值从未改变任何运行时行为:webhook 与 reconcile 共享同一套路由表,`POST /reconcile` 是独立端点。该变量只额外制造了一个必须被文档解释的分支 | 全仓零命中;`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` | | `RUN_MODE` 变量 + `Config.run_mode` 字段 + 启动校验块 | 已删除标识符 | 两种取值从未改变任何运行时行为:webhook 与 reconcile 共享同一套路由表,`POST /reconcile` 是独立端点。该变量只额外制造了一个必须被文档解释的分支 | 全仓零命中;`NG-SERVER-MODE` 作为设计非目标保留在 `docs/charter.md` |
| `jmap_password()` 安全访问器 | 已删除方法 | 为 `SecretString` 字段提供不泄密的读取入口,仅在 env 解析路径中有意义 | `jmap_password` 作为业务配置**字段名**保留(`BusinessConfigWire` / `BusinessConfig`),字段本身即 `SecretString` | | `jmap_password()` 安全访问器 | 已删除方法 | 为 `SecretString` 字段提供不泄密的读取入口,仅在 env 解析路径中有意义 | `jmap_password` 作为业务配置**字段名**保留(`BusinessConfigWire` / `BusinessConfig`),字段本身即 `SecretString` |
| `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 6 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token`,与 `BusinessConfig` 同构 | | `Config.port` / `Config.redis_url` 字段 | 已删除字段 | `port` 在两个构造器里硬编码 `8080`,而监听绑定直接从环境变量取值——字段值可与真实监听端口静默不一致,且全仓零读取方;`redis_url` 同样零读取 | `Config` 收窄为 7 字段:`telegram` / `jmap` / `account_id` / `llm` / `auth` / `worker_token` / `timezone`(末项镜像自 `BusinessConfig`,供 worker 渲染时间戳) |
| 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 | | 两个构造器的 `redis_url` 参数 | 已删除参数 | 唯一读者(原 main.rs 第 72 行)在上一轮改造中被移除 | `redis_only()` 与 `from_business(value)`;包装器 `from_business_json` / `from_business_value` 同步去掉首参 |
| 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 | | 19 个遗留环境变量名 | 已删除读取 | 见上;这些名字在代码中已无任何读取方 | 名单见 `docs/reference.md` §5.3;语义见 `docs/design.md` §7.1 |
| `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification`(worker.rs:234 在用)。这是 src/ 里最后 3 个 `#[expect(dead_code)]` | | `Channel` / `Notifier` / `MessageAdapter`(`src/channel.rs`)+ `UserCommand`(`src/domain.rs`) | 已删除占位 trait 与类型 | 三者**无实现、无调用方、无 dyn 绑定**,`#[expect(dead_code)]` 属性是仅有的引用来源;实际 Telegram 出站走 `channel::telegram::TelegramClient`,由 `worker.rs` 的 `MetadataWorker` 与 `notify.rs` 直接持有,从未经过它们。`UserCommand` 的唯一使用者是被删的 `Channel` | `channel.rs` 只留 `pub mod telegram`(`TelegramClient`,`reqwest` 自研);`domain.rs` 只留领域 `Notification`(worker.rs:234 在用)。这是 src/ 里最后 3 个 `#[expect(dead_code)]` |