Commit Graph

21 Commits

Author SHA1 Message Date
anonsaber b2dbe7ccbb refactor: delete three zero-implementor 'stage 0 placeholder' channel traits
src/channel.rs had Channel / Notifier / MessageAdapter, each marked
#[expect(dead_code, reason = "稳定ID+阶段0占位…")]. All three had no
implementor, no caller and no dyn binding: Telegram outbound actually goes
through channel::telegram::TelegramClient::send_notification -> send_text,
held directly by worker.rs's MetadataWorker and notify.rs. The traits were
never on any path.

- delete the three traits + their #[async_trait] attributes
- drop the async_trait import from channel.rs (unused afterwards); the
  dependency stays in Cargo.toml, still used by 5 other files
- drop the domain import from channel.rs (its only uses were inside the
  deleted traits; the telegram module has its own Notification import)
- delete UserCommand from src/domain.rs -- its only consumer was Channel.
  Notification stays (worker.rs:234 builds it)
- rewrite channel.rs's module doc: "only an adapter boundary in stage 0
  (no bot commands yet)" was already false -- /help, /search, /summary
  and /ai on|off are all parsed in worker.rs::parse_intent

The three dead-code attributes were the last three #[expect(dead_code)] in
src/. Two #[expect(clippy::too_many_arguments)] remain in notify.rs; those
are justified lint suppressions on router wiring, not placeholders.

External contracts unchanged: HTTP routes, Redis keys, error codes and the
error envelope are untouched.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (unchanged).
2026-09-29 02:23:38 +08:00
anonsaber 2da11b9f92 docs+fix: delete the legacy env compat layer; sync every doc that described it
Code (src/config.rs 521->394, src/main.rs 232->200):
- Config::from_env() + required_secret / required_nonblank / env_bool deleted.
  config.rs now performs no std::env read; all business fields come from Redis
  business config (BusinessConfigWire -> validate_business_wire -> validate_nonblank).
- RUN_MODE deleted: variable, Config.run_mode field and the main.rs validation
  block. The two modes never changed runtime behaviour -- webhook and reconcile
  share one router, POST /reconcile is a standalone endpoint.
- jmap_password() accessor deleted; the field name is kept in
  BusinessConfig / BusinessConfigWire because it is real data, not a stub.
- Config.port and Config.redis_url deleted: both had zero readers, and port was
  hardcoded to 8080 in both constructors while the listener binds straight from
  the PORT env var, so the field could silently disagree with the real port.
  Config is now 6 fields: telegram / jmap / account_id / llm / auth / worker_token.
- redis_only() and from_business(value) signatures simplified accordingly.
- #[expect(dead_code)] count in src/ is now 0.

Test coverage: the from_env()-driven startup_rejects_blank_auth_secret case is
gone with the path it tested. The non-leak assertion it carried was reattached
to the real validation route (BusinessConfigWire -> TryFrom -> validate_nonblank)
with a stronger third assertion that the rejected value is not echoed either.

Docs: design.md 7.1 re-scoped to the Redis business config table; reference.md
5.1/5.3 rewritten (no legacy env path); deployment.md 5 and the runtime-mode
table rewritten; retired.md gains section 4 registering every deletion;
README x2, .env.example, charter.md updated.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (was 62; the deleted case). GATE-DOCS: PASS 5/5, ANCHOR 247 line-refs /
0, SECTION-REF 216 / 0.
2026-09-29 02:02:44 +08:00
anonsaber 638c3a8cbc docs: consume all four self-checks; make the file-size rule enforceable
Every member's audit is now read and each real finding fixed, not just reported.

AGENTS.md §2.1
- The 500-line soft cap now has a machine-checkable exception: an over-limit
  file must carry a one-line `SPLIT-EVAL:` marker stating the split decision.
- Six files were over the cap with no justification: notify.rs 2036, state.rs
  1586, worker.rs 1045, config.js 1079, client.rs 601, config.rs 521. All now
  carry the marker. client.rs was missed by my own earlier line count, which
  did not descend into src/domain/jmap/.
- New validator scripts/docs_check/check_file_size.py; GATE-DOCS 4/4 -> 5/5.
  It proves the marker exists, not that the written reason is sound.
- Re-based 144 line anchors across six docs for the +1 line each file gained;
  every shifted anchor verified against the code, not just for non-blankness.

audit-arch.md
- design.md §6.1 module matrix omitted the `debug` module entirely; added.
- Seven numeric duplications in design.md now defer to reference.md §6.1 as
  the single authority instead of restating the values.
- reference.md §5.1 was missing DEBUG_TOKEN; §5.3 listed six recognised names
  and guessed `JMAP_*`. It now lists all 20 exactly as config.rs reads them.
- Corrected two wrong claims in the audit itself: debug.rs is compiled
  unconditionally, not under #[cfg(debug_assertions)] (there are no cargo
  features at all), and /debug/* exposes 7 routes, not the invented session
  list / resend / rebuild surface.

audit-fileplanner.md
- reference.md cited a nonexistent `jmap_service.rs`; replaced with the real
  adapter chain worker.rs -> domain/jmap.rs -> domain/jmap/client.rs.
- cloudflare-worker/README.md was outside every validator's scope; all four
  doc validators now include it (208 section refs, 250 line refs).
- README.zh-CN.md stated only half of what README.md says about RUN_MODE; the
  forward-placeholder sentence is now bilingual.
- charter.md §7 boundary table was missing rows for itself and for web/.

audit-rust.md / audit-frontend.md
- Error envelope corrected: `error` is a bare machine code, no `message`.
- Status table, AI-consent labels and ~15 stale or phantom facts fixed against
  notify.rs; `TelegramClient::SendMessage` and `web_config` phantoms removed.
- GET|PUT /api/enabled forwarded by the worker so the SPA kill switch works.
- Ten UI strings that called the admin credential the Redis ACL password now
  say CONFIG_ENCRYPTION_KEY.
- POST /healthz returns 405; dead HTTP_STATUS removed.
- audit_anchors.py now flags identifiers that do not exist in the file they
  are anchored to.

charter.md
- `SAF-ENABLE-FLAG` registered (85 IDs); `SAF-ADMIN-SESSION` row corrected;
- `REQ-PUSH-TYPES` registered; the stale `admin_session_digest` removed.

Gates: GATE-DOCS PASS 5/5 (250 line-refs, 208 section refs, 0 errors);
GATE-P0 62 passed / 0 failed / 1 ignored, baseline unchanged; worker and SPA
36 tests, 0 failures.

Real Stalwart / Telegram / managed Redis verification stays blocked on missing
credentials and remains labelled as unverified.
2026-09-29 01:16:11 +08:00
anonsaber b70d188864 docs+fix: sweep stale and phantom references; log the actual bound port
Code (2 sites):
- main.rs logged bootstrap_config.port, which is always the hardcoded 8080 in
  the two-variable production config; the listener actually binds the port
  parsed at main.rs:31-34. Log the bound port.
- That exposed Config.port as genuinely unread (clippy -D warnings), so mark it
  with #[expect(dead_code, reason=...)] instead of silently deleting a
  documented env-parsing site.

Docs, phantom identifiers (names that do not exist in src/):
- RuntimeConfig x3 -> OutboundConfig via RuntimeConfigProvider (state.rs:34/41)
- LlmClient::noop() x2 -> None in Option<Arc<LlmClient>> (main.rs:125)
- main.rs::validate_env_or_exit x2 -> config.rs:338 read, main.rs:75 validation
- TelegramChannel::send_message -> TelegramClient::send_text (channel.rs:134)

Docs, stale facts:
- LLM table: ENABLED and ALLOW_NET defaulted true,true -> false,false;
  API_KEY, BASE_URL, MODE were "required" -> only required when enabled. This
  contradicted design.md:385-387, which already had the correct values.
- design.md:560 said RUN_MODE "is actually consumed"; it is only read and
  validated and never affects behaviour (main.rs:73-74).
- RUN_MODE operator docs (README, reference, deployment, .env.example) now say
  both accepted values produce identical behaviour.
- retired.md: 5 intents -> 6 (/search shipped, worker.rs:517); 4 LLM env vars
  -> 6; main.rs tracing events 6 -> 7.
- design.md:494 tracing count 6 -> 7, debug-gate warning added to the list.
- design.md:350 code-gap count 2 -> 1 (multi-instance window done in 6c99ce5).
- design.md:554 marks the 45-paused stage-0 baseline explicitly as historical.
- .env.example said the Redis ACL password was the bootstrap trust root; it is
  CONFIG_ENCRYPTION_KEY (main.rs:88-91).

Docs, re-anchored after the main.rs shrink (6 refs shifted by -2).

GATE-P0 62 passed / 0 failed / 1 ignored; GATE-DOCS 4/4 clean
(228 line anchors, 211 section refs, 0 errors).
2026-09-28 22:33:27 +08:00
anonsaber bfe0fd88aa feat: Telegram /search 邮件搜索(email/query Filter + SearchSnippet/get 高亮)
⑥-c 落地代码缺口:/search <关键词>

实现
- JmapBackend::search_emails 加 trait 默认实现(mock 后端返回 InvalidRequest,
  真机后端用 Email/query + text Filter 取 ID,再 SearchSnippet/get 取高亮)
- JmapService::search_emails 暴露搜索入口,与 list_emails_page 共享超时/错误映射
- worker:Intent::Search + /search 分发分支 + /help 说明行

降级路径(不 panic、不回栈给用户)
- SearchSnippet/get 不支持(unknownMethod/timeout)→ 仍返回匹配 ID 列表,
  渲染降级为高亮片段暂不可用
- 无匹配 → 没有找到匹配「…」的邮件。
- JMAP 侧失败 → SearchReply::Retry,交由协调器 503 + Retry-After 重试
- 搜索关键词为空 → 引导提示,不发请求

安全边界
- subject/preview 只做展示:strip_mark_tags 剥 <mark> 高亮(大小写不敏感、
  未闭合标记丢弃,不泄漏原始标记),unescape_html_entities 单趟解码,
  先剥标签再解码实体以保留邮箱正文里字面 <mark>
- 无匹配 ID / 主题 / preview 分别有截断上限(120/120/160 字符)
- 每条命中带 email_id,可直接接 /summary <email_id>
- 中文关键词仅前缀匹配,避免帮我搜一下发票被劫持成搜索、或覆盖授权意图

测试 +7(55 → 62 passed, 0 failed, 1 ignored)
2026-09-28 21:09:06 +08:00
anonsaber 6c99ce51ab state: XAUTOCLAIM 空闲阈值改由运行超时配置推导,封顶 6h(01a0e75f-f9b1)
现状:read_batch 里 const SINGLE_EVENT_CEILING_MS = 300_000 是硬编码,
而真实单事件上限 = (max_retries+1) x (jmap+telegram+llm 三个 timeout),
运维把任一超时调大后实际耗时可能远超 300s → 空闲阈值欠保护,
多实例会把还在处理的条目提前认领,造成重复投递。

改动(ROADMAP 缺口③「多实例重复窗口」):
- single_event_ceiling_ms(config) 纯函数:(max_retries+1) x (jmap+telegram+llm) x 2
  (SAFETY),并用 .max(SINGLE_EVENT_CEILING_FLOOR_MS=300_000) 兜底,
  零值配置不会把窗口缩到 legacy 常量之下。
- reclaim_idle_threshold_ms(config, count) 纯函数:count.max(1) x 单事件上限,
  再 .min(RECLAIM_IDLE_THRESHOLD_CAP_MS = 6h) 防误调把恢复拖到天级。
- read_batch 调用 self.get_outbound_config()(trait 方法,同实现内已有,未改签名),
  Ok 走推导值,Err 回退 count x 300_000(不破坏投递主流程、不传播 Redis 错误)。

既有闸门不动:notify.rs 投递前查 delivery:committed、60s delivery:inflight 在途租约、
retry_or_dlq 3 次。本次只修阈值本身,性质保持「永不丢、只可能重复;单实例不受影响」。

:356-364 那段注释改写为描述真实机制(旧文「~300s 上限」已不准确),
未写入任何未经测试证明的断言。

测试:3 个纯函数单测覆盖 —— 默认配置 (15+10+30)x4x2=440s >= 300s floor、
大超时配置阈值更大、零值配置不缩到 floor 之下、荒唐超时被封顶 6h、count=0 用 1 事件兜底。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 55 passed / 0 failed / 1 ignored。
2026-09-28 18:38:00 +08:00
anonsaber f4cae00adc telegram: 重试之间加指数退避,429 尊重服务端 retry_after(01a0e75f-f7e3)
现状:send_text 的 for _ in 0..=max_retries 重试循环在两次尝试之间零延迟,
任何 5xx/超时/429 都以最短间隔连续打 API,429 场景下反而更容易触发二次限流。

改动(ROADMAP 缺口②「Telegram 429 退避」):
- backoff_delay_ms(attempt: u8) 纯函数:起步 250ms、每轮 x2、单轮封顶 4s。
  调用方 sleep 只走这一个出口,便于单测直接断言序列而无需真实等待。
- retry_after_ms(body) 纯函数:解析 429 响应体 parameters.retry_after(秒),
  转毫秒后钳到 60s 上限;缺字段/非数字/非正/非有限值一律回退到指数退避。
- 总退避预算上限 60s:耗尽后 delay 归零不再 sleep,避免单次 send_text 长时间挂起。
- 429 单独分支,错误串改为 telegram request rate limited (429) 以便区分限流与一般失败。

语义不变:max_retries 仍 cap 5、每次请求仍受 telegram_timeout_ms 约束、
返回 Result<(), String>、错误串不泄漏 token。

测试:client() 通过 with_sleep_override 注入 no-op sleep,退避逻辑完整走通但不真实等待。
新增 3 个测试:429 端到端(mock 返回 parameters.retry_after=1,断言 2..=3 次请求)、
backoff 序列(250/500/1000/2000/4000 后封顶)、retry_after 解析与钳制。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 52 passed / 0 failed / 1 ignored。
2026-09-28 18:13:08 +08:00
anonsaber afa6cec8ef debug: 新增 /debug/* 远程联调面,双因子门禁默认绝对关闭(01a0e319)
新增 src/debug.rs:/debug/ping、/config、/redis、/jmap、/telegram、/worker 六个只读
探针 + POST /debug/notify 一条测试出站消息。

SAF-DEBUG-GATE 双因子,缺任一即完全不挂载路由(请求落到通用 404,无"未配置即放行"
降级):
- 启动命令带 --debug(main.rs 最小 std::env::args 解析,未新增依赖)
- Secret 环境变量 DEBUG_TOKEN 非空

两层执行点:main.rs 推导 debug_token 并传入 router builder(None 则 debug_router 不被
merge);debug.rs 侧 auth_token 缺失时直接返回空 Router。

SAF-DEBUG-AUTH:每个 handler 先做 Authorization: Bearer DEBUG_TOKEN 常数时间比较
(复用 worker_authorized),失败 401,鉴权通过前不产生任何副作用。

探针只读侧全部复用现成实现,不另写一套:Redis 走 is_enabled,JMAP 走
probe_jmap_session,Telegram 走 probe_telegram_get_me,出站走唯一构造点
business_client(notify.rs 新增 pub(crate))到 send_text,/debug/notify 与
refresh_business_config 共用同一条客户端构造路径。

SAF-NO-SECRET-ECHO / SAF-LOG-PURITY:/debug/config 只回"是否配置"、计数与超时等
运行时参数,bot token / JMAP password / worker / webhook / reconcile token / LLM key
一律不出现在响应体;DEBUG_TOKEN 只在鉴权内部暴露,不回显、不入日志,日志仅一条
"debug endpoints enabled" 且不含令牌值。allowlist 用 chat_allowlist_snapshot 取生效值
(运行时业务配置优先、否则启动值),/debug/notify 显式 chat_id 不在 allowlist 则 403。

ARCH-LB-WORKER 白名单不含 /debug/*,该面仅供直连 origin 联调,公网入口不可达。

AGENTS.md 登记 4 个稳定 ID:MOD-DEBUG / SAF-DEBUG-GATE / SAF-DEBUG-AUTH /
REQ-DEBUG-ENDPOINTS(§2 第 19 条硬边界、§5 测试条、§7 索引 4 行)。

调试路由测试(4 项,notify.rs + debug.rs 内):Bearer 放行 200、缺失/错误令牌 401、
未提供 DEBUG_TOKEN 因子时 /debug/* 完全不注册而落 404、/debug/notify POST 未认证 401。

门禁四段全绿(rust:1-slim-bookworm):cargo fmt --all -- --check / cargo check --locked
/ cargo clippy --locked --all-targets -- -D warnings / cargo test --locked =
49 passed / 0 failed / 1 ignored。
2026-09-28 15:00:49 +08:00
anonsaber 800d6e0d00 notify: SPA 管理凭据改用 CONFIG_ENCRYPTION_KEY,解除与 Redis ACL 密码耦合
原实现把 SPA 管理凭据派生自 REDIS_URL 的 ACL 密码,并在 handler 侧用
`.is_empty()` 直接拒绝空凭据。托管 Redis 常见配置是 TLS-only 无密码,
那种情况下 `.is_empty()` 恒为真,管理界面永久 401 —— `POST /api/bootstrap`
与 `POST /api/admin/session` 双双不可用,无法完成初始化。

改为直接取 CONFIG_ENCRYPTION_KEY:它本就是启动必填 Secret(64 位 hex /
32 字节,见 config::encryption_key_from_env),启动时已校验,因此生产环境
不可能出现空凭据,handler 侧的空串守卫随之失效,一并移除。Redis ACL 密码
回归本职:只用于数据库连接。

- main.rs:env 只读一次(encryption_key_raw),admin_token 与已校验值不可能
  分叉;删除 Url::parse(redis_url).password() 分支。
- notify.rs:AppState 字段 bootstrap_token → admin_token(4 个构造点同步),
  两处 handler 删除空串守卫,bootstrap() 文档不再称 Redis ACL bootstrap。
- 测试更名 bootstrap_rejects_missing_admin_credential:断言语义不变(测试态
  空凭据收到 Bearer 仍 401),名称改为反映新语义。

门禁全绿:cargo fmt --check / check --locked / clippy --all-targets -D warnings
/ test --locked = 45 passed, 0 failed, 1 ignored。
2026-09-28 12:43:33 +08:00
anonsaber c5bc7b8d82 fix(notify): /ready JMAP 探针对齐真实客户端(Basic 认证 + 路径归一)
架构师复核指出两点会致全站不可用的缺陷(notify.rs):

- 探针未带认证:真实 jmap_client 以 .credentials() 取 session,Stalwart
  session 端点需认证;无认证必 401 → /ready 永久 503 → ingress 停止路由。
  改为从 business_runtime 快照取 jmap_username/jmap_password,
  basic_auth 随请求发送(零新增依赖,凭据仅入请求头,绝不日志)。
- session URL 未归一:配置若是裸 origin,GET 根路径必 404 → 误报。
  改为复用 normalize_session_url(client.rs 改为 pub(crate))并请求
  标准 /.well-known/jmap,与 JmapClientBackend::connect_with_runtime 同路径。

顺带:两探针 tokio::join! 并行,/ready 最坏耗时 3s→3s(原 6s)。

门禁 4/4 绿(45 passed, 0 failed, 1 ignored)。
2026-09-28 10:37:22 +08:00
anonsaber c0aab5e966 feat(notify): /ready 接入 JMAP session 与 Telegram getMe 端到端探测
ARCH-READY-BASELINE 从「配置完整性 + Redis 可达性」升级为端到端就绪:

- PROBE_TIMEOUT=3000ms 各探针独立超时(notify.rs:69)
- cached_jmap_session_url / cached_bot_token 先克隆后 await,
  避免跨 .await 持有 RwLockReadGuard(非 Send)
- probe_jmap_session:GET JMAP session URL,error_for_status
- probe_telegram_get_me:bot token 仅用于拼 URL,绝不日志
- ready():四者全过返 200 + 就绪报告 JSON(jmap/telegram 真实结果),
  任一失败 503 + Retry-After: 30(标准错误 envelope)
- 探针只读、无状态写入;token 永不回显(SAF-NO-SECRET-ECHO)

门禁:fmt / check / clippy / test 4/4 绿(45 passed, 0 failed, 1 ignored)。
2026-09-28 10:15:52 +08:00
anonsaber ed93358e29 feat(notify): unify /ready not-ready 503 into the error envelope
- GET /ready: ready -> 200 + diagnostic report (unchanged success body);
  not ready -> 503 + {"error":"service_unavailable","request_id":id}
  + Retry-After: 30, consistent with the other public entry points
- drop the legacy {"status":"not_ready"} diagnostic block from the 503 body
  (backward compatibility not required)
- HTTP 200/503 semantics preserved, so Uptime Kuma HTTP(s) monitors
  (status-code based) keep working
- sync docs: README.md, README.zh-CN.md, docs/design.md, docs/deployment.md,
  AGENTS.md, docs/todo.md
2026-09-27 13:11:29 +08:00
anonsaber f16b747672 feat(notify): unify public-route error envelope and add push route tests
- all public write routes now return {"error":code,"request_id":id};
  retryable 503 responses add Retry-After: 30; 204 and success bodies unchanged
- GET /ready keeps its structured diagnostic JSON (Uptime Kuma runtime contract)
- add HTTP tests for the push register/disable routes
2026-09-27 12:53:22 +08:00
anonsaber 309a38c503 fix(worker,state,jmap,config): close independent-review findings
- worker/state: paginate Email/changes by widening maxChanges on the same
  sinceState (jmap-client 0.4.2 has no upToId) to avoid skipping a batch when
  advancing per page; make dedup+enqueue atomic via claim_dedup_and_enqueue
  (Lua SET NX EX then XADD); scale the XAUTOCLAIM idle threshold by batch size;
  XACK payload-less malformed stream entries out of the PEL via ack_malformed;
  fail closed on unknown streams (Err); tighten SET NX EX TTL to .max(1);
  derive the CSPRNG fallback owner-token from time+PID+counter instead of a
  constant; clear the push verification digest on push:disable
- worker: /help menu now lists only the consent triggers the parser accepts
- jmap/config: complete client and configuration validation
2026-09-27 12:53:05 +08:00
anonsaber 956ef6a1d3 feat(config): add global Redis-backed enable switch 2026-09-25 20:11:35 +08:00
anonsaber 4ecfa44485 fix(redis): install explicit rustls crypto provider 2026-09-25 18:02:21 +08:00
anonsaber a57cb4f75d fix(redis): enable TLS for Upstash connections 2026-09-25 17:45:24 +08:00
anonsaber e18cecca16 refactor: remove legacy project identity 2026-09-25 10:40:10 +08:00
anonsaber 8a24ec7e9e fix: report all missing setup requirements 2026-09-25 09:42:15 +08:00
anonsaber e5ec3aef4c feat: provide configuration setup mode 2026-09-25 09:30:45 +08:00
anonsaber dceecaf2f4 feat: initial MessageWeave release 2026-09-25 09:14:04 +08:00